SLOPSHOPPER

kodflow-hud

Optional heads-up display beside the status line, as a mod (function hooks, Claude Code 2.1.293 or later): a Tasks pane with the open epics of kodflow-hooks'…

newpanerowsguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kodflow-hud
│ ┃ Tâches ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./client.tsx │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /hud │ ⎿ kodflow-hud: kodflow-hud: Tâches open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tâches
▣ client module ./client.tsx
Pane · hud-agents
─ aucun sous-agent en cours──────────────────────────── AGENT MODÈLE ÉTAT ◉ principal opus ● actif · 0 s /work/app/src W cache.ts W audit.ts W auth.ts
README

kodflow-hud

An optional heads-up display beside the status line. Two panes and a few toasts, nothing else: the status line keeps the context gauge, the epics' pills and everything it already draws, and this mod shows only what it does not.

  • Tâches — the open epics (« épopées ») of the session's task list as a tree (R12): subtasks under their task, for each one who works it (« ◉ principal » or « ↳ name · role » in that subagent's own colour), the model and effort it runs on, how long since it moved, what it waits on or unblocks, the critical path in the margin, and the tasks waiting on the person. Tab turns it into a graph by state (R4). The tab counts the tasks left to do: « Tâches (2) ». It is the view a session starts on.
  • Agents — only while a subagent runs, its tab counting them (« Agents (1) »): the main loop on one line, then each subagent with its name, its role (its type, short), its model, its state (a frozen one in red), the task it holds, the folder it works in and the files it last read, wrote or committed.
  • Toasts — a task done, a task blocked (waiting), a task gone silent: at most one per task and per state.
  • Coloured words — a few words of a reply drawn in colour in the transcript, never a whole paragraph and never an emoji (see Coloured words).

It is a mod: a hooks module (hooks/mod/register.tsx) the engine loads from hooks/hooks.json → modules. It needs Claude Code 2.1.293 or later (tested on 2.1.296, the version the CI pins) and reads the task list that kodflow-hooks keeps; without kodflow-hooks the Tasks pane says there is no list and the Agents pane works on its own.

Install, open, close

/plugin install kodflow-hud@kodflow

scripts/install.sh at the repository root installs it with the others. claude plugin disable kodflow-hud@kodflow turns it off without touching the rest.

CommandEffect
/hudopens the Tasks pane, and the Agents pane while a subagent runs
/hud tasks (tâches)opens the Tasks pane
/hud agentsopens the Agents pane while a subagent runs; with none, says so and opens it with the first
/hud off (close)closes both; the Agents tab no longer opens by itself until /hud

With autoOpen on (the default) the Tasks pane opens when a session starts, alone, so it is the one shown. The Agents tab opens with the first subagent and closes with the last: the main loop alone never gets a tab. Every poll (3 s) the two tabs are retitled when their count moved. An open or a retitle may raise the pane it names; the pane shown before is opened again after, so the view does not jump to Agents. The engine seats a pane nobody asked for only where it has room — from 144 columns, docked beside the transcript in the fullscreen layout — and keeps it waiting below that; /hud seats it at any width.

What it looks like

Captures in text of the drawn tree, read back by the tests at the pane's minimum: 90 columns with its frame (88 of body) by 20 rows. Colour is the text's alone, and always one of the theme's own colours, which the engine shades for a light or a dark theme: merged (purple) the epic and the critical path, warning (amber) the task under way, success (green) done, planMode (teal) ready, permission the tasks waiting on the person, error (red) blocked or frozen, ide (blue) the model, claude the main loop. Each subagent has one mid-tone colour of its own for the session (eight, picked from its id, none a state's), the same in both panes. No cell paints a background, so a transparent terminal shows through.

R12, the tree (the view a pane opens on):

─ #1 Routage model/effort ──────────────────────────────────────────────────────────────
  TÂCHE                           AGENT                   MODÈLE         ÂGE  LIENS
  ◆ #1     Routage model/effort  7/15                                 1 h 52
  ├▸ ✓ T0     Événements réels    ◉ principal             opus           2 h  ⇢ C6 C7
  ├▸ ✓ C1–C5  Policy et routage   ◉ principal             opus        1 h 20  ⇢ C9
┃ ├▾ ● C6     Porte Fable         ↳ mkt-routi… · general  opus·xhigh  12 min  ⇢ C9
  │  ├─ ✓ agent.spawn             ◉ principal             opus        20 min
┃ │  ├▾ ● Grant de session        ↳ mkt-routi… · general  opus·xhigh   3 min  ⇢ #10
  │  │  ├─ ✓ Test resume          ◉ principal             opus         8 min
┃ │  │  └─ ⊘ Test parallèle                                                   ⇠ #8
  │  └─ ○ Repli shell
┃ ├─ ◇ C7     Feedback agents     toi                                  6 min  ⇢ C9
┃ ├▸ ▶ C8     Spécialistes ×5     prête                                       ⇢ C9
┃ └─ ⊘ C9     CI et PR                                                        ⇠ C6 C7 C8
┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
┃ chemin critique  C6 → C9  ·  C7 → C9  ·  C8 → C9  ·  #8 → #10      bloqué par toi (C7)
● C6  Porte Fable   mkt-routing-pr · general-purpose · opus·xhigh · 12 min · ⇢ C9 · figé
  Le mod juge, il ne réécrit jamais e.model
────────────────────────────────────────────────────────────────────────────────────────
Arbre   Graphe                         ↑↓ choisir · ⏎ plier/déplier · tab changer de vue

R4, the graph (tab, or a click on « Graphe »):

─ #1 Routage model/effort ──────────────────────────────────────────────────────────────
FAIT · PRÊT                    EN COURS · ATTENTE             BLOQUÉ
✓ T0    Événements réels ──┬─▶ ● C6    Porte Fable ───────┐
  principal · opus · 2 h   │     mkt-routing-pr · 12 min  │
                           └─▶ ◇ C7    Feedback agents ───┤
                                 attend toi · 6 min       │
✓ C1–C5 Policy et routage ────────────────────────────────┼─▶ ⊘ C9    CI et PR
  principal · 1 h 20                                      │     attend C6 C7 C8
▶ C8    Spécialistes ×5 ──────────────────────────────────┘
  prête · personne dessus




┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
prochaine action  ▶ C8 prête, personne dessus                     t'attend  ◇ C7 · 6 min
┃ chemin critique  C6 → C9  ·  C7 → C9  ·  C8 → C9  ·  #8 → #10
livrable  ⊘ C9 CI et PR · attend C6 C7 C8
────────────────────────────────────────────────────────────────────────────────────────
Arbre   Graphe                         ↑↓ choisir · ⏎ plier/déplier · tab changer de vue

The tree, column by column

Every column is a Box of a fixed width, a gap of 2 between them; what goes in one is cut to it first, counted in terminal cells (string-width's count: wide characters and emoji two, the ambiguous glyphs ✓ ● ◆ ▶ ┃ ⇢ one), so nothing is lined up with spaces and a long title never shifts a column.

ColumnCellsWhat
margin2┃ for a task on the critical path
tree303 a level, the glyph 2, the ref 7 (top level), the title 17, cut with …
agent22◉ principal (the main loop, theme colour claude), ↳ name · role (a subagent in its own colour, the role its type short: general, a specialist's topic; the name cut first, the role keeping up to seven cells; no role when the type is unknown), toi for the person's, prête for a ready task
model10opus·xhigh, sonnet·med: the family and the effort, shortened to fit
age6right-aligned: 45 s, 12 min, 1 h 20, 2 h, 3 j; red when silent
links10⇠ C6 C7 C8 what it waits on, else ⇢ C9 what it unblocks, then +n

An epic's line carries no agent and no model: its title runs over those columns, followed by its progress, 7/15 (green once every task is done). The tasks filed under no epic sit under a quiet ◌ Hors épopée, with no number, no progress and no age. The section's title never repeats the tab's name: the epic when one is open (#1 Routage model/effort), else how many (2 épopées ouvertes), then the tasks under no epic (1 tâche hors épopée).

Glyphs: ✓ done, ● under way, ◇ waiting on the person, ▶ ready — pending, every blocker done, nobody on it —, ⊘ blocked — pending with a blocker left —, ○ a subtask left to do under a task not done (whoever takes the task takes it). ▸/▾ a task with subtasks, folded or not. The critical path is the longest blockedBy chain of tasks not done, down to the epic's final tasks; every task on a chain of that length gets the margin, the footer names the chains and the task that waits on the person (« bloqué par toi » when it is on the path).

Rows, at 20: the title, the headers, the epic (done/total, its age), 11 tasks, a rule, the critical path, two lines on the selection (its glyph, ref, title, agent and role, model, age, links, CI, « figé » when silent, then its description) and the views' bar. One top-level task is open at a time.

Narrower than 88 cells of body the links go, then the model under 76, then the agent under 64: below that, the tree and the age. Fewer than 20 rows, the finished top-level branches fold into « ✓ fait (n) » first, then the detail drops to one line, then to none, then the list scrolls with the selection.

The graph

Three columns of 26 — FAIT · PRÊT, EN COURS · ATTENTE, BLOQUÉ — and two gutters of 5; each top-level task on two rows, its title then its agent and age. A gutter joins its links on a vertical (┬ ┐ ┤ ┼ ┘ └), links that share no lane on stretches of their own; a task feeding the right-hand column directly runs on a lane the middle one leaves free, as a solid line, so no two lines cross. Links within a column, or leftwards, are not drawn (the tree shows them). The footer gives the next action (a ready task, on the path first), what waits on the person, the critical path and the deliverable (the epic's final task, and what it waits on). Narrower than 88 cells the three columns share what is left; under 40 a node no longer holds its glyph, ref and title, and the graph says so in place of its lanes.

Keys and mouse

Key or clickEffect
↑ ↓choose a task
⏎ (or space)fold or unfold it; opening a top-level task folds the other
tabtree ↔ graph
click on a taskchoose it; on a task with subtasks, or a second click, fold it
click on « Arbre » / « Graphe »that view

The tree is a Client region (hooks/mod/client.tsx, a surface module): the engine hands it keys once a click has given it the focus (Escape gives the focus back) and every pointer event over it, in cells (ClientSurface.onKey, ClientSurface.onPointer in the engine's claude-code typings). The selection, the open branches and the view live in that instance's state, across redraws. The terminal and the desktop draw a Client; VS Code and the mobile app do not, and get the same tree drawn still, at its first state.

Agents, 88 columns by 10 rows (tests/agents.test.tsx), its tab « Agents (3) »:

─ 3 sous-agents en cours ───────────────────────────────────────────────────────────────
  AGENT               RÔLE                    MODÈLE      ÉTAT
◉ principal           fil principal           opus        ● actif · 15 s
    /w/repo
↳ hud-agents          general-purpose                     ● actif · 15 s
    ● #2 Rendu des agents  ·  /w/repo
↳ vault-audit         devops · hashicorp                  ⏸ figé · 11 min
    /w/repo
↳ sans-type           type inconnu                        ◇ attend

The main loop heads the list in the theme's claude colour; each subagent follows in its own colour (the same as in the Tâches pane), the ones at work first, then for half an hour, dimmed, the ones that stopped. The role is the agent's type (subagent_type) short: the plugin prefix dropped, a specialist's <area>-specialist-<topic> read area · topic; a type nobody gave reads « type inconnu », never a guess. The state: ● actif (green, a call in the last minute), ● en cours (amber), ◇ attend (waiting), ⏸ figé (red, bold: no call for silentMinutes), ◌ au repos, ✓ terminé, ✗ échec. Under each, the task it holds and its folder, then, as rows allow, the files it last read (R, violet), wrote (W, orange) or committed (C, green), as filetree colours them. Under 76 columns the role moves under the name; under 56 the model goes.

Where each thing comes from

ShownSource
epics and tasks<config>/kodflow/sessions/<session>/tasks.json (version 2, kodflow-hooks' file), re-read whenever its modification time moves, looked at every 3 s and right after a task_* call
worker, its statethe task's worker / worker_id, matched against $.agent.list(); kept once the task moves on (tasks.json drops it)
subtasks, links, refthe task's parent, blockedBy, ref (kodflow-hooks' task_create / task_update)
ready, blocked, critical pathderived from those links, nothing typed
model · efforta subagent's: the Agent call's effort and its result's resolvedModel, the model agent.spawn resolved, and kodflow-hooks' agent-models.tsv (its PostToolUse record); the main loop's: $.session.model(), no effort (none is said to a mod). A loop no source names has an empty cell, never a guess
time since it movedthe later of the task's updated and the last tool call of its loop (worker, else the agent that owns it)
silentin progress, and its loop stopped, gone from the list, or quiet for silentMinutes
toitasks with assignee: "human", when tasks.json carries the field
folder of a loopthe last call that names one: a file tool's file, Grep/Glob/LS's path, a leading cd X or git -C X in a shell command (the status line's activity/dir.go), taken to the root of its git work tree
files of a loopRead (read); Write, Edit, MultiEdit, NotebookEdit (write); a git commit turns the loop's writes under that repository to commit. Kept 45 s, eight per loop, five shown at most
CIgh pr checks <url> for the GitHub pull request URLs an open task cites, every 5 minutes at most

Nothing is a second source of truth: the panes draw from $.state, which holds what was last read from tasks.json, $.agent.list() and the tool calls; a reload of the module reads them again.

What it never does

  • Change or refuse a call. tool.call is observed after next: the result goes back as it came, even when the observation fails (.catch replays next). A refused call is not recorded.
  • Add anything else to what the model reads: no tool, no context; one system prompt section, the colour convention, and none with KODFLOW_COLORS=off. /hud answers one short line, as any command does.
  • Scan the disk. No find, no fs.list; finding a work tree's root costs one exists per parent folder the first time a folder is seen, none after. The only process it runs is gh pr checks, and only for cited pull requests.

Coloured words

The model may wrap a few words of a reply in a mark; the transcript draws them in colour, bold, and the mark is not shown:

MarkColourMeaning
{{r:words}}redwhat blocks
{{o:words}}orangewhat needs attention
{{v:words}}greenwhat waits on the person, their answer or their action
{{p:words}}pinka decision the person already made, cited with its date

The convention reaches the model as one system prompt section, kodflow-hud:colors, added last by a prompt.compose hook on the session side of the cache boundary. It is composed afresh for every prompt, so it holds after a compaction. It says when to mark and how: a few words, a few marks a message at most, never in code, a command, a path or a table meant to be copied, and p only for a decision of the person's whose date is cited. It is left out where nothing draws the reply: a -p run (the print trait), --bare, a render with no surface.

A ui.render hook on AssistantMessage redraws a reply that holds a mark, paragraph by paragraph (paragraphs split on blank lines): a marked paragraph becomes coloured runs, every other one, and every fenced code block, goes back to the engine's Markdown. A mark inside a code block, an unclosed fence or inline code is shown as typed. A reply with no mark is the engine's own drawing, untouched. A failure while drawing falls back to the engine's (.catch replays next(e)).

KODFLOW_COLORS=off (or 0, false, no) turns off both the drawing and the prompt section.

Limits:

  • A marked paragraph keeps its bold and loses the rest of its markdown: links, italics, a heading's or a list's markers are drawn as typed. The convention tells the model to keep them out of a marked paragraph.
  • The marks only reach a transcript that draws. A reply read elsewhere (a subagent's answer read by its parent, a log, the stored transcript) carries them as typed; ctrl+o shows the stored text too.
  • Where an organization manages Claude Code, an installed plugin's prompt.compose hook does not run (claude plugin validate says so): the drawing works, but the model is not told the convention.

Options

Set them in /config or under pluginConfigs["kodflow-hud"].options.

OptionDefaultMeaning
silentMinutes10quiet time after which a task in progress is flagged silent
autoOpentrueopen the Tasks pane when a session starts, and the Agents tab while a subagent runs
citrueask gh pr checks about cited pull requests

KODFLOW_HUD_TRACE=/absolute/path.json writes what the panes would draw to that file when the session ends: how a run with no pane (claude -p, a bench) shows what was collected.

Where it draws nothing

  • claude -p: there is no screen; the hooks run (collection, trace), no pane is drawn and toasts go nowhere. Expected.
  • VS Code: the extension does not show a mod's panes. Expected.
  • A terminal narrower than 144 columns: the panes opened at start wait until /hud or a wider window.

Grey panel on a transparent terminal

The panes sit on an opaque grey block even when the terminal background is transparent. The mod is not the cause and cannot remove it: it draws no background of its own (grep backgroundColor hooks/mod finds nothing).

In Claude Code 2.1.300 the engine wraps the docked pane column in a box painted with the theme token composerSidebarBackground (rgb(38, 38, 38) in the dark preset, ansi256(235) once the terminal maps it). $.ui.open (PaneOpenArgs: id, title, focus, closeOnEscape, holdToasts, rows, columns) has no option for it, the Pane render props carry none, and a backgroundColor inside the tree is read only as rgb(), #hex, ansi256(), ansi:<name> or a theme key: nothing means "terminal default". The token is not in the documented list of custom theme overrides.

Workarounds, none of them in this plugin:

  • Ghostty: set background-opacity-cells = true (available since 1.2.0). Per the Ghostty reference, the opacity then also applies to cells that carry an explicit background colour, which is the case of the grey block.
  • Any terminal: a custom theme in ~/.claude/themes/ can override composerSidebarBackground with the colour of your terminal background. The token is undocumented and read from the 2.1.300 bundle, not from the documentation, and the override is an opaque colour: it hides the grey without making the pane see-through.

Until the engine offers a transparent or default-background pane surface, this stays one of its limits.

Known limits

  • A commit is recognised from the command, not from git: git commit written inside an echo counts, and files committed that the loop did not write through a file tool are not listed.
  • Files written by a shell command are not seen: only the folder it points at. Seeing them would mean a scan after every command.
  • $.agent.list() drops a subagent some time after it ends; its worker then reads absent, which counts as stopped.
  • A toast is raised once per task and state for the session: a task that goes silent, moves, then goes silent again is toasted once.
  • Whether opening or retitling a pane raises it is the engine's to decide, and its typings do not say. The mod assumes the worst (every open raises) and opens the pane shown before again after its own opens; the tests prove that against a host that raises, not against the terminal itself. With a focused pane the person holds, re-raising it does not take the keyboard.

Tests

claude plugin validate --strict --json plugins/kodflow-hud   # only the "version" warning
claude plugin test plugins/kodflow-hud                        # pure modules and wiring

tests/colors.test.tsx holds the coloured words: the split into marked paragraphs and runs (code blocks, inline code, bold, unknown marks), the kodflow-hud:colors section last and session, absent with KODFLOW_COLORS=off, under -p and --bare, and a marked reply drawn on the terminal through the engine, the markup gone, the code block left to the engine's markdown. tests/pure.test.ts holds the pure modules (board, silence, toasts, activity, paths, CI). tests/tree.test.tsx draws the « Tâches » pane through the engine and reads it back as text (tests/capture.ts, which cuts every Box to its width as the terminal lays it out): both views at 90 by 20, line for line, every line 88 cells and no background; the columns dropping at 86, 70 and 58; the folds, then the detail, under 20 rows; ↑ ↓ ⏎ tab and clicks on a task and on the views' bar, through the Client's own key and pointer listeners; the same tree on the surfaces with no Client; the derived states, the critical path, the cells (model·effort, links, ages, wide glyphs) and a model column filled only from what the session said. tests/wiring.test.tsx loads the module in the engine with the host in memory and a mocked clock: « Tâches » alone at start and shown, the Agents tab opened and counted with the subagents and closed with the last while « Tâches » stays in front (the host raising every pane it opens), /hud off keeping it shut, panes mounted at several widths on the terminal and desktop surfaces, each loop in its colour, results returned untouched, refused calls not recorded, toasts once per task and state, CI at most every five minutes, no process and no folder listing on a tool call. tests/agents.test.tsx draws the reported case: one epic and one loose task, the main loop alone (no Agents tab, the word « Tâches » only on the tab), then three subagents (a general-purpose one, a specialist, one of unknown type, one frozen), both panes line for line, the colours, and the Agents pane at 60 columns.

A real run, for the attribution by agent (the kit's $.tool.call carries no agentId):

KODFLOW_HUD_TRACE=$PWD/trace.json claude -p --plugin-dir plugins/kodflow-hud \
  --allowedTools Read Agent "Bash(ls:*)" "Bash(cd:*)" -- \
  "Read a.txt, then launch one general-purpose subagent that reads b/c.txt and runs: cd b && ls"

trace.json then holds two loops under activity: main with a.txt, and the subagent's id with b/c.txt and the folder b.

Source 15 files
hooks/mod/register.tsx 639 lines
1// register.tsx — kodflow-hud's function hooks: wiring only.
2//
3// Two panes and a few toasts, beside the status line and never in its place
4// (the context segment, the epics' pills and the rest stay there):
5//   « Tâches »  the open epics of tasks.json as a tree (subtasks, what each
6//               task waits on, the critical path) or as a graph by state,
7//               who works each task, on what model, how long since it moved,
8//               the silent ones and the ones waiting on the person; the
9//               view a session starts on, its tab counting the tasks left;
10//   « Agents »  only while a subagent runs, its tab counting them: the main
11//               loop, then each subagent with its role, model, state, task,
12//               folder and last files (syncPanes opens, retitles, closes).
13// And a few words of each reply in colour (colors.ts): the transcript draws
14// `{{r:words}}` red, `{{o:…}}` orange, `{{v:…}}` green, `{{p:…}}` pink.
15//
16// Everything is read, nothing is decided: tasks.json is kodflow-hooks' file,
17// re-read whenever its modification time moves; the agents come from
18// `$.agent.list()`; the activity from `tool.call`, observed after `next`
19// and handed back untouched. No tool, no context line, no refusal: this mod
20// can stop nothing, and adds one thing only to what the model reads, the
21// colour convention's system prompt section (off with KODFLOW_COLORS=off,
22// which also turns off the drawing). Every hook fails open.
23//
24// The logic lives in the pure modules beside this file (board.ts, tree.ts,
25// layout.tsx, models.ts, activity.ts, alerts.ts, ci.ts, view.tsx); the
26// « Tâches » pane's selection and folding live in its surface module,
27// client.tsx; what is here reads `$`.
28
29import { atom, read, update } from "claude-code";
30import type { Register } from "claude-code";
31import type {
32  HudActivity,
33  HudAgent,
34  HudBoard,
35  HudCi,
36  HudModel,
37  HudSeen,
38  HudTask,
39} from "../../types";
40import { MAIN, parseBoard, parseTasks, type Ctx } from "./board.ts";
41import { fold, observe, prune } from "./activity.ts";
42import { alertsFor, trimKeys } from "./alerts.ts";
43import { CI_EVERY_MS, ciState, prsToCheck } from "./ci.ts";
44import { configDir, dirname, sessionDir, tasksFile } from "./paths.ts";
45import { agentsView } from "./view.tsx";
46import { running } from "./who.ts";
47import { buildTree } from "./tree.ts";
48import { draw, initialUi, screenOf } from "./layout.tsx";
49import {
50  PROMPT_SECTION,
51  SECTION_ID,
52  blocks,
53  colorsOff,
54  drawn,
55  marked,
56} from "./colors.ts";
57import {
58  fromAgentCall,
59  keepSession,
60  merge,
61  modelKey,
62  parseModels,
63  seenFrom,
64} from "./models.ts";
65
66const boardAtom = atom(
67  { plugin: "kodflow-hud", key: "board" } as const,
68  {
69    found: false,
70    epics: [],
71  } as HudBoard,
72);
73const agentsAtom = atom(
74  { plugin: "kodflow-hud", key: "agents" } as const,
75  [] as HudAgent[],
76);
77const activityAtom = atom(
78  { plugin: "kodflow-hud", key: "activity" } as const,
79  {} as Record<string, HudActivity>,
80);
81const ciAtom = atom(
82  { plugin: "kodflow-hud", key: "ci" } as const,
83  {} as Record<string, HudCi>,
84);
85const toastedAtom = atom(
86  { plugin: "kodflow-hud", key: "toasted" } as const,
87  [] as string[],
88);
89const modelsAtom = atom(
90  { plugin: "kodflow-hud", key: "models" } as const,
91  {} as Record<string, HudModel>,
92);
93const seenAtom = atom(
94  { plugin: "kodflow-hud", key: "seen" } as const,
95  {} as Record<string, HudSeen>,
96);
97const primedAtom = atom({ plugin: "kodflow-hud", key: "primed" } as const, "");
98const tickAtom = atom({ plugin: "kodflow-hud", key: "tick" } as const, 0);
99
100export const TASKS_PANE = "hud-tasks";
101export const AGENTS_PANE = "hud-agents";
102const PANES = [
103  { id: TASKS_PANE, title: "Tâches" },
104  { id: AGENTS_PANE, title: "Agents" },
105] as const;
106
107/** « Tâches (2) »: the tasks left to do across the open epics. */
108export const tasksTitle = (board: HudBoard): string => {
109  const n = board.epics
110    .flatMap((e) => e.tasks)
111    .filter((t) => t.status !== "completed").length;
112  return n ? `Tâches (${n})` : "Tâches";
113};
114
115/** « Agents (1) »: the subagents at work, the main loop not counted. */
116export const agentsTitle = (agents: readonly HudAgent[]): string => {
117  const n = running(agents).length;
118  return n ? `Agents (${n})` : "Agents";
119};
120
121/** How often tasks.json's time, the agents and the TTLs are looked at. */
122export const POLL_MS = 3_000;
123/** The elapsed times redraw on this step, not on every poll. */
124const TICK_MS = 10_000;
125const TASK_TOOL = /^mcp__kodflow-hooks__task_(create|update|epic|focus)$/;
126
127// What the module keeps between polls. A reload starts these over, which
128// costs one re-read of tasks.json; everything drawn is in `$.state`.
129let polling = false;
130let checking = false;
131let syncing = false;
132// Whether the Agents tab opens by itself when a subagent starts: autoOpen at
133// first; off once the person closes that tab or runs /hud off, on again with
134// /hud or /hud agents
135let agentsWanted = true;
136let lastSession = "";
137let lastMtime: number | undefined = -1;
138let modelsMtime: number | undefined = -1;
139let modelsPath = "";
140let allTasks: HudTask[] = [];
141let lastCi = 0;
142let root: string | undefined;
143let home: string | undefined;
144const gitRoots = new Map<string, string>();
145
146// Atoms are written only when their value changed: every write redraws
147const same = (a: unknown, b: unknown): boolean =>
148  JSON.stringify(a) === JSON.stringify(b);
149
150/**
151 * The root of the git work tree a folder lies in, else the folder: so the
152 * view does not jump between the sub-folders of one project. One stat per
153 * level the first time a folder is seen, none after.
154 */
155async function gitRoot($: any, dir: string): Promise<string> {
156  const known = gitRoots.get(dir);
157  if (known) return known;
158  let found = dir;
159  for (let d = dir, i = 0; i < 40; d = dirname(d), i++) {
160    if (await $.fs.exists(`${d}/.git`).catch(() => false)) {
161      found = d;
162      break;
163    }
164    if (d === "/") break;
165  }
166  gitRoots.set(dir, found);
167  return found;
168}
169
170async function tasksPath($: any, session: string): Promise<string | undefined> {
171  const cfg = configDir(await $.env.get("CLAUDE_CONFIG_DIR"), home);
172  return cfg ? tasksFile(sessionDir(cfg, session)) : undefined;
173}
174
175/** Fold what a source says of some loops into the models kept. */
176async function noteModels(
177  $: any,
178  said: Record<string, HudModel>,
179): Promise<void> {
180  const session = await $.session.id();
181  const fold = (before: Record<string, HudModel>) => {
182    const after = keepSession(before, session);
183    for (const [id, m] of Object.entries(said)) {
184      const key = modelKey(session, id);
185      after[key] = merge(after[key], m);
186    }
187    return after;
188  };
189  // Folded inside the update, on the latest record: two Agent results
190  // arriving together each keep the other's entry
191  const now = await read($, modelsAtom);
192  if (!same(now, fold(now))) await update($, modelsAtom, fold);
193}
194
195/**
196 * kodflow-hooks' agent-models.tsv, when its time moved, and the main loop's
197 * model: what the tree's model column reads beside the calls seen live.
198 */
199async function pollModels($: any, tasks: string): Promise<void> {
200  const path = tasks.replace(/tasks\.json$/, "agent-models.tsv");
201  const mtime = await $.fs.stat(path).then(
202    (s: { mtimeMs: number }) => s.mtimeMs,
203    () => undefined,
204  );
205  const said: Record<string, HudModel> = {};
206  // Another session's file is read afresh whatever its time says; a read
207  // that failed is not remembered as done, so the next poll tries again
208  if (mtime !== undefined && (path !== modelsPath || mtime !== modelsMtime)) {
209    const text: string | undefined = await $.fs
210      .read(path)
211      .catch(() => undefined);
212    if (text !== undefined) {
213      Object.assign(said, parseModels(text));
214      modelsMtime = mtime;
215      modelsPath = path;
216    }
217  } else if (mtime === undefined) {
218    modelsMtime = undefined;
219    modelsPath = path;
220  }
221  const main = await $.session.model().catch(() => undefined);
222  if (typeof main === "string" && main) said[MAIN] = { model: main };
223  if (Object.keys(said).length) await noteModels($, said);
224}
225
226function ctxOf(
227  now: number,
228  agents: HudAgent[],
229  activity: Record<string, HudActivity>,
230  silentMs: number,
231): Ctx {
232  return { now, agents, activity, silentMs };
233}
234
235/** One look at the world: tasks.json, the agents, the TTLs, the toasts. */
236async function poll($: any, silentMs: number, ci: boolean): Promise<void> {
237  if (polling) return;
238  polling = true;
239  try {
240    const now = await $.clock.now();
241    const session = await $.session.id();
242    const path = await tasksPath($, session);
243    if (path) {
244      const mtime = await $.fs.stat(path).then(
245        (s: { mtimeMs: number }) => s.mtimeMs,
246        () => undefined,
247      );
248      if (session !== lastSession || mtime !== lastMtime) {
249        const text =
250          mtime === undefined
251            ? undefined
252            : await $.fs.read(path).catch(() => undefined);
253        let raw: unknown = undefined;
254        try {
255          raw = text === undefined ? undefined : JSON.parse(text);
256        } catch {
257          raw = undefined;
258        }
259        allTasks = parseTasks(raw);
260        const board = parseBoard(text);
261        if (!same(await read($, boardAtom), board))
262          await update($, boardAtom, () => board);
263        const seen = seenFrom(allTasks, await read($, seenAtom), session);
264        if (seen) await update($, seenAtom, () => seen);
265        lastSession = session;
266        lastMtime = mtime;
267      }
268      await pollModels($, path);
269    }
270    const listed = await $.agent.list().catch(() => []);
271    const agents: HudAgent[] = listed.map((a: any) => ({
272      id: a.id,
273      // Its name, else the few words of its task: its type is its role,
274      // shown beside the name, so it does not stand in for one
275      label: a.name || a.description || a.type || a.id.slice(0, 8),
276      status: a.status,
277      ...(a.name && { name: String(a.name) }),
278      ...(a.type && { type: String(a.type) }),
279    }));
280    if (!same(await read($, agentsAtom), agents))
281      await update($, agentsAtom, () => agents);
282    await syncPanes($).catch(() => undefined);
283    const pruned = prune(await read($, activityAtom), now);
284    if (pruned) await update($, activityAtom, () => pruned);
285
286    const ctx = ctxOf(now, agents, await read($, activityAtom), silentMs);
287    const baseline = (await read($, primedAtom)) !== session;
288    const toasted = await read($, toastedAtom);
289    const { alerts, keys } = alertsFor(
290      session,
291      allTasks,
292      ctx,
293      new Set(toasted),
294      baseline,
295    );
296    if (keys.length)
297      await update($, toastedAtom, (k) => trimKeys([...k, ...keys]));
298    if (baseline) await update($, primedAtom, () => session);
299    for (const a of alerts) $.ui.toast(a.text);
300
301    const tick = Math.floor(now / TICK_MS);
302    if ((await read($, tickAtom)) !== tick)
303      await update($, tickAtom, () => tick);
304    if (ci && now - lastCi >= CI_EVERY_MS) {
305      lastCi = now;
306      void checkCi($);
307    }
308  } finally {
309    polling = false;
310  }
311}
312
313/** `gh pr checks` for each pull request an open task cites, one at a time. */
314async function checkCi($: any): Promise<void> {
315  if (checking) return;
316  checking = true;
317  try {
318    const prs = prsToCheck((await read($, boardAtom)).epics);
319    for (const pr of prs) {
320      const r = await $.process
321        .run(["gh", "pr", "checks", pr], { timeoutMs: 20_000 })
322        .catch(() => undefined);
323      const state = r ? ciState(r.exitCode, r.stdout, r.stderr) : "error";
324      const at = await $.clock.now();
325      await update($, ciAtom, (m) => ({ ...m, [pr]: { state, at } }));
326    }
327  } finally {
328    checking = false;
329  }
330}
331
332/** Fold one finished tool call into its loop's activity. */
333async function record($: any, e: any, r: any): Promise<void> {
334  const loop: string = e.agentId ?? MAIN;
335  const obs = observe(String(e.tool), e, root, home);
336  // A call that failed still says the loop is alive, not that it touched files
337  if (r?.isError) {
338    obs.files = [];
339    delete obs.commit;
340  }
341  const dir = obs.dir ? await gitRoot($, obs.dir) : undefined;
342  const commit = obs.commit ? await gitRoot($, obs.commit) : undefined;
343  const now = await $.clock.now();
344  await update($, activityAtom, (m) => ({
345    ...m,
346    [loop]: fold(m[loop], loop, obs, now, dir, commit),
347  }));
348}
349
350/**
351 * The panes as the counts want them, every poll. « Tâches » carries the
352 * tasks left, « Agents » the subagents at work, retitled only when the count
353 * moved; the Agents tab opens when a subagent starts (unless the person
354 * closed it, or turned autoOpen off and never asked) and closes when none is
355 * left, so the main loop alone never gets a tab. An open or a retitle may
356 * raise the pane it names: the pane shown before is shown again after, so
357 * the « Tâches » view the session starts on stays in front.
358 */
359async function syncPanes($: any): Promise<void> {
360  if (syncing) return;
361  syncing = true;
362  try {
363    const panes: any[] = await $.ui.panes().catch(() => []);
364    if (!Array.isArray(panes)) return;
365    const find = (id: string) => panes.find((p) => p.id === id);
366    const shown = panes.find((p) => p.isShown);
367    const tTitle = tasksTitle(await read($, boardAtom));
368    const agents = await read($, agentsAtom);
369    const aTitle = agentsTitle(agents);
370    let moved = false;
371    const tasks = find(TASKS_PANE);
372    if (tasks && tasks.title !== tTitle) {
373      await $.ui.open({ id: TASKS_PANE, title: tTitle });
374      moved = true;
375    }
376    const pane = find(AGENTS_PANE);
377    if (running(agents).length === 0) {
378      if (pane) await $.ui.close({ id: AGENTS_PANE });
379    } else if (pane ? pane.title !== aTitle : agentsWanted) {
380      await $.ui.open({ id: AGENTS_PANE, title: aTitle });
381      moved = true;
382    }
383    if (!moved || !shown) return;
384    const after: any[] = await $.ui.panes().catch(() => []);
385    const front = after.find((p) => p.isShown);
386    const back = after.find((p) => p.id === shown.id);
387    if (back && front?.id !== shown.id)
388      await $.ui.open({ id: back.id, title: back.title });
389  } finally {
390    syncing = false;
391  }
392}
393
394async function openPanes(
395  $: any,
396  which: readonly { id: string; title: string }[],
397) {
398  const placed: string[] = [];
399  const board = await read($, boardAtom);
400  const agents = await read($, agentsAtom);
401  for (const p of which) {
402    const title = p.id === TASKS_PANE ? tasksTitle(board) : agentsTitle(agents);
403    // The Agents tab stands only while a subagent runs: asked for now, it
404    // opens with the first one
405    if (p.id === AGENTS_PANE && running(agents).length === 0) continue;
406    const r = await $.ui.open({ id: p.id, title });
407    if (r.isPlaced) placed.push(title);
408  }
409  return placed;
410}
411
412/** Write what the panes would draw to KODFLOW_HUD_TRACE, when it is set. */
413async function trace($: any): Promise<void> {
414  const path = await $.env.get("KODFLOW_HUD_TRACE");
415  if (!path || !path.startsWith("/")) return;
416  await poll($, settings.silentMs, false);
417  const dump = {
418    board: await read($, boardAtom),
419    agents: await read($, agentsAtom),
420    activity: await read($, activityAtom),
421  };
422  await $.fs.write(path, JSON.stringify(dump, null, 1));
423}
424
425// The options, as register received them
426const settings = { silentMs: 10 * 60_000, ci: true };
427
428/** Poll now, in the background; a failure waits for the next period. */
429function kick($: any): void {
430  void poll($, settings.silentMs, settings.ci).catch(() => undefined);
431}
432
433export const register: Register = (on, options) => {
434  settings.silentMs = Math.max(1, Number(options.silentMinutes ?? 10)) * 60_000;
435  settings.ci = options.ci !== false;
436  const autoOpen = options.autoOpen !== false;
437
438  on("session.start", async ($, e, next) => {
439    root = await $.session.root().catch(() => undefined);
440    home = await $.env.get("HOME");
441    await $.command.register({
442      name: "hud",
443      description:
444        "kodflow-hud: open the Tasks and Agents panes (/hud tasks, /hud agents, /hud off)",
445    });
446    agentsWanted = autoOpen;
447    // Unasked, the engine seats a pane only where it has room (144 columns);
448    // below that it waits, and /hud seats it at any width. Only « Tâches »
449    // opens here, so it is the view the session starts on: the Agents tab
450    // comes with the first subagent (syncPanes)
451    if (autoOpen) await $.ui.open(PANES[0]).catch(() => undefined);
452    $.clock.every(POLL_MS, () => kick($));
453    kick($);
454    return next(e);
455  }).catch(($, e, next) => next(e));
456
457  // A run with no pane (`claude -p`, a bench) can still show what was
458  // collected: KODFLOW_HUD_TRACE names a file, written at the session's end
459  on("session.end", async ($, e, next) => {
460    await trace($);
461    return next(e);
462  }).catch(($, e, next) => next(e));
463
464  on("command.run", { command: "hud" }, async ($, e) => {
465    const arg = e.args.trim().toLowerCase();
466    if (arg === "off" || arg === "close") {
467      agentsWanted = false;
468      for (const p of PANES)
469        await $.ui.close({ id: p.id }).catch(() => undefined);
470      return { text: "kodflow-hud: panes closed." };
471    }
472    const tasksOnly = arg === "tasks" || arg === "tâches" || arg === "taches";
473    const agentsOnly = arg === "agents";
474    if (!tasksOnly) agentsWanted = true;
475    const which = tasksOnly
476      ? PANES.filter((p) => p.id === TASKS_PANE)
477      : agentsOnly
478        ? PANES.filter((p) => p.id === AGENTS_PANE)
479        : PANES;
480    kick($);
481    const placed = await openPanes($, which);
482    if (agentsOnly && !placed.length)
483      return {
484        text: "kodflow-hud: no subagent is running; the Agents tab opens with the first one.",
485      };
486    return {
487      text: placed.length
488        ? `kodflow-hud: ${placed.join(", ")} open.`
489        : "kodflow-hud: no room to seat a pane here (a -p run or a host without panes).",
490    };
491  });
492
493  // The person closing the Agents tab means it: it stays closed until /hud
494  on("ui.close", async ($, e, next) => {
495    if (e.origin === "person" && e.id === AGENTS_PANE) agentsWanted = false;
496    return next(e);
497  }).catch(($, e, next) => next(e));
498
499  // Observation only: the call runs first, its result goes back as it came
500  on("tool.call", async ($, e, next) => {
501    const r: any = await next(e);
502    if (r?.deny === undefined) {
503      await record($, e, r).catch(() => undefined);
504      if (TASK_TOOL.test(String(e.tool))) kick($);
505      if (e.tool === "Agent" && !r?.isError) {
506        const said = fromAgentCall(e as { effort?: unknown }, r?.result);
507        if (said)
508          await noteModels($, { [said.id]: said.model }).catch(() => undefined);
509      }
510    }
511    return r;
512  }).catch(($, e, next) => next(e));
513
514  // The model core resolved for a subagent it started, whoever asked
515  on("agent.spawn", async ($, e, next) => {
516    const r: any = await next(e);
517    if (r?.deny === undefined && typeof r?.agentId === "string" && r.model)
518      await noteModels($, { [r.agentId]: { model: String(r.model) } }).catch(
519        () => undefined,
520      );
521    return r;
522  }).catch(($, e, next) => next(e));
523
524  on(
525    "ui.render",
526    { component: "Pane", requestId: TASKS_PANE },
527    async ($, e) => {
528      const els: any = $.ui.resolve(e);
529      await read($, tickAtom);
530      const [board, agents, activity, ciMap, models, seen] = await Promise.all([
531        read($, boardAtom),
532        read($, agentsAtom),
533        read($, activityAtom),
534        read($, ciAtom),
535        read($, modelsAtom),
536        read($, seenAtom),
537      ]);
538      const model = buildTree({
539        board,
540        ctx: ctxOf(await $.clock.now(), agents, activity, settings.silentMs),
541        ci: ciMap,
542        models,
543        seen,
544        session: await $.session.id(),
545      });
546      const width = e.props.bodyColumns;
547      const rows = e.props.scroll.bodyRows;
548      // Where the surface runs modules (terminal, desktop) the tree takes
549      // keys and clicks; elsewhere it is drawn still, at its first state
550      // (the table may name Client where the surface then drops it)
551      const { Client } = els;
552      if (Client && (e.surface === "terminal" || e.surface === "desktop"))
553        return (
554          <Client
555            key="tree"
556            module="./client.tsx"
557            props={{ model, width, rows }}
558            width={width}
559            height={rows}
560          />
561        );
562      return draw(els, screenOf(model, initialUi(model), width, rows), width);
563    },
564  );
565
566  on(
567    "ui.render",
568    { component: "Pane", requestId: AGENTS_PANE },
569    async ($, e) => {
570      const els = $.ui.resolve(e);
571      await read($, tickAtom);
572      const [agents, activity, models, board] = await Promise.all([
573        read($, agentsAtom),
574        read($, activityAtom),
575        read($, modelsAtom),
576        read($, boardAtom),
577      ]);
578      return agentsView(
579        els,
580        {
581          agents,
582          activity,
583          now: await $.clock.now(),
584          home,
585          models,
586          board,
587          session: await $.session.id(),
588          silentMs: settings.silentMs,
589        },
590        e.props.bodyColumns,
591        e.props.scroll.bodyRows,
592      );
593    },
594  );
595
596  // The colour convention, on the session's side of the cache boundary: a
597  // section composed afresh for every prompt, so a compaction keeps it. Only
598  // where a transcript will draw the reply (no -p run, no --bare)
599  on("prompt.compose", async ($, e, next) => {
600    const r = await next(e);
601    if (colorsOff(await $.env.get("KODFLOW_COLORS")) || !drawn(e)) return r;
602    const sections = r.sections.filter((s) => s.id !== SECTION_ID);
603    return {
604      ...r,
605      sections: [
606        ...sections,
607        { id: SECTION_ID, text: PROMPT_SECTION, scope: "session" as const },
608      ],
609    };
610  }).catch(($, e, next) => next(e));
611
612  // A reply with a mark is redrawn paragraph by paragraph: a marked one as
613  // coloured runs, any other as the engine's markdown. No mark: the engine's
614  // own drawing. Any failure draws the engine's
615  on("ui.render", { component: "AssistantMessage" }, async ($, e, next) => {
616    if (colorsOff(await $.env.get("KODFLOW_COLORS"))) return next(e);
617    const parts = blocks(e.props.text);
618    if (!marked(parts)) return next(e);
619    const { Box, Text, Markdown }: any = $.ui.resolve(e);
620    return (
621      <Box flexDirection="column" gap={1}>
622        {parts.map((p, i) =>
623          "runs" in p ? (
624            <Text key={`t${i}`}>
625              {p.runs.map((r, j) => (
626                <Text key={`r${i}-${j}`} color={r.color} bold={r.bold}>
627                  {r.text}
628                </Text>
629              ))}
630            </Text>
631          ) : (
632            <Markdown key={`m${i}`} text={p.markdown} />
633          ),
634        )}
635      </Box>
636    );
637  }).catch(($, e, next) => next(e));
638};
639
hooks/mod/board.ts 201 lines
1// board.ts — the task board read from tasks.json, and what each task's
2// worker is doing. Pure: no `$`, no I/O.
3//
4// tasks.json (version 2) is kodflow-hooks' file, a contract it shares with
5// the status line and lib/epics.jq; this module only reads it. An epic is
6// open as the status line counts it: a task left to do, or the agent's active
7// epic while it has no task yet. Tasks filed under no epic (epic 0) form one
8// more group, « Hors épopée », with no number (its id is 0): the pane's tab
9// already says « Tâches », so the group does not say it again.
10//
11// A task may also carry the tree's fields (kodflow-hooks writes them only
12// once set): `parent`, `blockedBy`, `ref`. A file without them is a flat
13// list, every task at the top.
14
15import type {
16  HudActivity,
17  HudAgent,
18  HudBoard,
19  HudEpic,
20  HudTask,
21} from "../../types";
22import { safe } from "./paths.ts";
23
24export const MAIN = "main";
25export const NO_EPIC_TITLE = "Hors épopée";
26/** What `$.agent.list()` reports for an agent whose loop still stands. */
27export const LIVE = new Set(["running", "waiting", "idle"]);
28
29type Obj = Record<string, any>;
30const isObj = (v: unknown): v is Obj =>
31  typeof v === "object" && v !== null && !Array.isArray(v);
32const text = (v: unknown): string =>
33  typeof v === "string" ? v : v === undefined || v === null ? "" : String(v);
34const num = (v: unknown): number =>
35  typeof v === "number" && Number.isFinite(v) ? v : 0;
36
37const PR_URL = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/g;
38
39/** The pull request URLs a task cites, once each, in order. */
40export function prsOf(...texts: string[]): string[] {
41  const seen = new Set<string>();
42  for (const t of texts) for (const m of t.match(PR_URL) ?? []) seen.add(m);
43  return [...seen];
44}
45
46function toTask(raw: Obj): HudTask {
47  const task: HudTask = {
48    id: text(raw.id),
49    subject: text(raw.subject),
50    status: text(raw.status) || "pending",
51    epic: Number.isInteger(raw.epic) ? raw.epic : 0,
52    agent: text(raw.agent) || MAIN,
53    updated: num(raw.updated) || num(raw.created),
54    prs: prsOf(text(raw.subject), text(raw.description)),
55  };
56  if (raw.worker) task.worker = text(raw.worker);
57  if (raw.worker_id) task.workerId = text(raw.worker_id);
58  if (raw.assignee) task.assignee = text(raw.assignee);
59  if (raw.description) task.description = text(raw.description);
60  if (raw.parent) task.parent = text(raw.parent);
61  if (Array.isArray(raw.blockedBy)) {
62    const ids = raw.blockedBy.map(text).filter((id: string) => id !== "");
63    if (ids.length) task.blockedBy = ids;
64  }
65  if (raw.ref) task.ref = text(raw.ref);
66  return task;
67}
68
69/** Every task of the file, deleted ones being already gone from it. */
70export function parseTasks(raw: unknown): HudTask[] {
71  if (!isObj(raw) || !Array.isArray(raw.tasks)) return [];
72  return raw.tasks
73    .filter((t: unknown) => isObj(t) && Object.hasOwn(t, "id"))
74    .map(toTask)
75    .filter((t: HudTask) => t.status !== "deleted");
76}
77
78/**
79 * The board: the open epics, most recently touched first, then the tasks
80 * filed under no epic while one of them is left to do.
81 */
82export function parseBoard(fileText: string | undefined): HudBoard {
83  if (fileText === undefined) return { found: false, epics: [] };
84  let raw: unknown;
85  try {
86    raw = JSON.parse(fileText);
87  } catch {
88    return { found: true, epics: [] };
89  }
90  const tasks = parseTasks(raw);
91  const data: Obj = isObj(raw) ? raw : {};
92  const active: Obj = isObj(data.active) ? data.active : {};
93  const epics: HudEpic[] = [];
94  for (const e of Array.isArray(data.epics) ? data.epics : []) {
95    if (!isObj(e) || !Number.isInteger(e.id)) continue;
96    const agent = text(e.agent) || MAIN;
97    const own = tasks.filter((t) => t.epic === e.id && t.agent === agent);
98    const left = own.some((t) => t.status !== "completed");
99    const emptyActive = own.length === 0 && active[agent] === e.id;
100    if (!left && !emptyActive) continue;
101    epics.push({
102      id: e.id,
103      title: text(e.title),
104      agent,
105      created: num(e.created) || num(e.touched),
106      touched: num(e.touched),
107      tasks: own,
108    });
109  }
110  epics.sort((a, b) => b.touched - a.touched);
111  const loose = tasks.filter((t) => t.epic === 0);
112  if (loose.some((t) => t.status !== "completed"))
113    epics.push({
114      id: 0,
115      title: NO_EPIC_TITLE,
116      agent: MAIN,
117      created: Math.min(...loose.map((t) => t.updated)),
118      touched: 0,
119      tasks: loose,
120    });
121  return { found: true, epics };
122}
123
124/** What the board needs from the world to judge a task. */
125export type Ctx = {
126  /** Milliseconds since the epoch. */
127  now: number;
128  agents: readonly HudAgent[];
129  activity: Readonly<Record<string, HudActivity>>;
130  /** How long an in_progress task may stay without activity. */
131  silentMs: number;
132};
133
134export type Verdict = {
135  /** The loop working the task: its worker, else the agent that owns it. */
136  loop: string;
137  /** That loop's state as `$.agent.list()` gives it; "main" for the main loop. */
138  state: string;
139  /** The last sign of life: the task's own change or a tool call of the loop. */
140  last: number;
141  silent: boolean;
142  /** Why it is silent: the loop stopped, or nothing happened for too long. */
143  why?: "stopped" | "quiet";
144};
145
146const findAgent = (agents: readonly HudAgent[], id: string) =>
147  agents.find((a) => a.id === id) ??
148  agents.find((a) => safe(a.id) === safe(id));
149
150/** The activity recorded for a loop, by its id or its path-safe spelling. */
151export function activityOf(
152  activity: Readonly<Record<string, HudActivity>>,
153  loop: string,
154): HudActivity | undefined {
155  return (
156    activity[loop] ??
157    Object.values(activity).find((a) => safe(a.agentId) === safe(loop))
158  );
159}
160
161/**
162 * Who works a task, how that loop stands, and whether the task went silent:
163 * in progress with its loop stopped, or with nothing from it for `silentMs`.
164 */
165export function judge(task: HudTask, ctx: Ctx): Verdict {
166  const loop = task.workerId || task.agent;
167  const updated = task.updated * 1000;
168  const seen = activityOf(ctx.activity, loop)?.last ?? 0;
169  const last = Math.max(updated, seen);
170  let state = MAIN;
171  if (loop !== MAIN) state = findAgent(ctx.agents, loop)?.status ?? "absent";
172  const verdict: Verdict = { loop, state, last, silent: false };
173  if (task.status !== "in_progress") return verdict;
174  // A worker that was named and is gone is the clearest case: nobody is on it
175  if (loop !== MAIN && !LIVE.has(state)) {
176    verdict.silent = true;
177    verdict.why = "stopped";
178  } else if (ctx.now - last >= ctx.silentMs) {
179    verdict.silent = true;
180    verdict.why = "quiet";
181  }
182  return verdict;
183}
184
185/** The label of the loop a task belongs to, for the board's rows. */
186export function loopLabel(loop: string, agents: readonly HudAgent[]): string {
187  if (loop === MAIN) return "principal";
188  return findAgent(agents, loop)?.label ?? loop.slice(0, 8);
189}
190
191/** "45s", "3m", "1h02", "2j": how long ago, in the fewest cells. */
192export function ago(ms: number): string {
193  const s = Math.max(0, Math.floor(ms / 1000));
194  if (s < 60) return `${s}s`;
195  const m = Math.floor(s / 60);
196  if (m < 60) return `${m}m`;
197  const h = Math.floor(m / 60);
198  if (h < 24) return `${h}h${String(m % 60).padStart(2, "0")}`;
199  return `${Math.floor(h / 24)}j`;
200}
201
hooks/mod/activity.ts 138 lines
1// activity.ts — where each agent works, from the tool calls it makes. Pure.
2//
3// Nothing here touches the disk: a location is what the call names, never
4// what a scan finds. The file tools name their file; Grep, Glob and LS their
5// folder; a shell command its folder the two ways the status line reads
6// (internal/adapter/activity/dir.go): a leading `cd X`, or `git -C X`. A
7// commit turns the agent's recent writes under that repository from write
8// to commit, since the files it took are not named anywhere without asking
9// git, and asking git on every call is the cost this mod refuses.
10
11import type { HudActivity, HudFile, HudOp } from "../../types";
12import { dirname, isUnder, resolvePath } from "./paths.ts";
13
14/** How long a touched file stays listed. */
15export const FILE_TTL_MS = 45_000;
16/** How many files a loop keeps; the view shows fewer when room is short. */
17export const MAX_FILES = 8;
18
19const WORD = `("[^"]+"|'[^']+'|[^\\s;&|]+)`;
20const CD = new RegExp(`^\\s*cd\\s+${WORD}`);
21const GIT_DIR = new RegExp(`(?:^|[\\s;&|(])git\\s+-C\\s+${WORD}`);
22// git, then any of its global options, then the commit verb
23const GIT_COMMIT =
24  /(?:^|[\s;&|(])git\s+(?:(?:-C|-c)\s+(?:"[^"]+"|'[^']+'|\S+)\s+|--no-pager\s+|--git-dir=\S+\s+|--work-tree=\S+\s+)*commit\b/;
25
26const unquote = (s: string): string =>
27  s.length >= 2 && (s[0] === '"' || s[0] === "'") && s.at(-1) === s[0]
28    ? s.slice(1, -1)
29    : s;
30
31const FILE_TOOLS: Record<string, { key: string; op: HudOp }> = {
32  Read: { key: "file_path", op: "read" },
33  Write: { key: "file_path", op: "write" },
34  Edit: { key: "file_path", op: "write" },
35  MultiEdit: { key: "file_path", op: "write" },
36  NotebookEdit: { key: "notebook_path", op: "write" },
37};
38const DIR_TOOLS = new Set(["Grep", "Glob", "LS"]);
39
40/** What one call says about where its loop works. */
41export type Observation = {
42  /** The folder it points at, absolute (a file's folder for a file tool). */
43  dir?: string;
44  files: { path: string; op: HudOp }[];
45  /** A commit was made in this folder (still to be taken to its root). */
46  commit?: string;
47};
48
49/**
50 * Read one tool call. `base` is where a relative path lands (the loop's
51 * current folder, else the session's root), `home` what `~` expands to.
52 */
53export function observe(
54  tool: string,
55  input: Readonly<Record<string, unknown>>,
56  base: string | undefined,
57  home: string | undefined,
58): Observation {
59  const at = (raw: unknown): string | undefined =>
60    typeof raw === "string" ? resolvePath(raw, base, home) : undefined;
61  const file = FILE_TOOLS[tool];
62  if (file) {
63    const path = at(input[file.key]);
64    return path
65      ? { dir: dirname(path), files: [{ path, op: file.op }] }
66      : { files: [] };
67  }
68  if (DIR_TOOLS.has(tool)) {
69    const dir = at(input.path);
70    return dir ? { dir, files: [] } : { files: [] };
71  }
72  if (tool === "Bash" && typeof input.command === "string") {
73    const command = input.command;
74    const git = GIT_DIR.exec(command);
75    const cd = CD.exec(command);
76    // git -C names the tree git works on; cd the folder the rest runs in
77    const dir = at(unquote((git ?? cd)?.[1] ?? "")) ?? undefined;
78    const obs: Observation = { files: [] };
79    if (dir) obs.dir = dir;
80    if (GIT_COMMIT.test(command)) {
81      const where = dir ?? base;
82      if (where) obs.commit = where;
83    }
84    return obs;
85  }
86  return { files: [] };
87}
88
89/**
90 * Fold one observation into a loop's record. `dir` is the folder to show
91 * (already taken to its repository's root by the caller), `commitRoot` the
92 * root a commit was made in.
93 */
94export function fold(
95  prev: HudActivity | undefined,
96  agentId: string,
97  obs: Observation,
98  now: number,
99  dir: string | undefined,
100  commitRoot: string | undefined,
101): HudActivity {
102  let files: HudFile[] = prev ? [...prev.files] : [];
103  for (const f of obs.files) {
104    files = files.filter((x) => x.path !== f.path);
105    files.unshift({ path: f.path, op: f.op, at: now });
106  }
107  if (commitRoot)
108    files = files.map((f) =>
109      f.op === "write" && isUnder(f.path, commitRoot)
110        ? { ...f, op: "commit", at: now }
111        : f,
112    );
113  const next: HudActivity = {
114    agentId,
115    files: files.slice(0, MAX_FILES),
116    last: now,
117  };
118  const where = dir ?? commitRoot ?? prev?.dir;
119  if (where) next.dir = where;
120  return next;
121}
122
123/** Drop the files older than the TTL; undefined when nothing changed. */
124export function prune(
125  activity: Readonly<Record<string, HudActivity>>,
126  now: number,
127  ttl = FILE_TTL_MS,
128): Record<string, HudActivity> | undefined {
129  let changed = false;
130  const out: Record<string, HudActivity> = {};
131  for (const [id, a] of Object.entries(activity)) {
132    const files = a.files.filter((f) => now - f.at < ttl);
133    if (files.length !== a.files.length) changed = true;
134    out[id] = files.length === a.files.length ? a : { ...a, files };
135  }
136  return changed ? out : undefined;
137}
138
hooks/mod/alerts.ts 63 lines
1// alerts.ts — which tasks deserve a toast now. Pure.
2//
3// Three states are news: a task completed, a task blocked (waiting), a task
4// gone silent. Each is toasted at most once per task, keyed
5// `<session>:<task>:<state>`, the keys kept in `$.state` so a reload of the
6// module does not toast them again. The first board read in a session is the
7// baseline: what stands then is recorded without a toast, so opening a
8// session full of finished work does not fire a volley of them.
9
10import type { HudTask } from "../../types";
11import { judge, type Ctx } from "./board.ts";
12
13export type Alert = { key: string; text: string };
14
15/** The news state of a task, if it is in one. */
16export function newsOf(
17  task: HudTask,
18  ctx: Ctx,
19): "done" | "waiting" | "silent" | undefined {
20  if (task.status === "completed") return "done";
21  if (task.status === "waiting") return "waiting";
22  if (judge(task, ctx).silent) return "silent";
23  return undefined;
24}
25
26function line(task: HudTask, state: string, ctx: Ctx): string {
27  const head = `#${task.id} ${task.subject}`;
28  if (state === "done") return `${head} : terminée`;
29  if (state === "waiting") return `${head} : bloquée, en attente`;
30  const v = judge(task, ctx);
31  return v.why === "stopped"
32    ? `${head} : silencieuse, son worker est arrêté`
33    : `${head} : silencieuse, aucune activité depuis ${Math.round(ctx.silentMs / 60000)} min`;
34}
35
36/**
37 * The alerts to raise and the keys to record. With `baseline`, every key is
38 * recorded and nothing is raised.
39 */
40export function alertsFor(
41  session: string,
42  tasks: readonly HudTask[],
43  ctx: Ctx,
44  toasted: ReadonlySet<string>,
45  baseline: boolean,
46): { alerts: Alert[]; keys: string[] } {
47  const alerts: Alert[] = [];
48  const keys: string[] = [];
49  for (const task of tasks) {
50    const state = newsOf(task, ctx);
51    if (!state) continue;
52    const key = `${session}:${task.id}:${state}`;
53    if (toasted.has(key)) continue;
54    keys.push(key);
55    if (!baseline) alerts.push({ key, text: line(task, state, ctx) });
56  }
57  return { alerts, keys };
58}
59
60/** Keep the recorded keys bounded: the newest `max`. */
61export const trimKeys = (keys: readonly string[], max = 500): string[] =>
62  keys.length > max ? keys.slice(keys.length - max) : [...keys];
63
hooks/mod/ci.ts 34 lines
1// ci.ts — the CI state of the pull requests the tasks cite. Pure.
2//
3// Read by `gh pr checks <url>`, whose exit code says it: 0 every check
4// passed, 8 some still pending, 1 one failed (or gh itself failed, which its
5// output tells apart). Only the pull requests an open task cites are asked,
6// and no more often than every CI_EVERY_MS.
7
8import type { HudCiState, HudEpic } from "../../types";
9
10export const CI_EVERY_MS = 5 * 60_000;
11
12/** The pull requests to ask about: cited by a task not completed. */
13export function prsToCheck(epics: readonly HudEpic[]): string[] {
14  const seen = new Set<string>();
15  for (const e of epics)
16    for (const t of e.tasks)
17      if (t.status !== "completed") for (const pr of t.prs) seen.add(pr);
18  return [...seen];
19}
20
21/** What `gh pr checks` answered, as one state. */
22export function ciState(
23  exitCode: number,
24  stdout: string,
25  stderr: string,
26): HudCiState {
27  if (/no checks reported/i.test(stderr + stdout)) return "none";
28  if (exitCode === 0) return "pass";
29  if (exitCode === 8) return "pending";
30  // 1 is a failed check when gh printed the checks, an error otherwise
31  if (exitCode === 1 && /\bfail/i.test(stdout)) return "fail";
32  return "error";
33}
34
hooks/mod/paths.ts 85 lines
1// paths.ts — where a session's files are, and how a path is spelled. Pure.
2//
3// configDir, safe and sessionDir are kodflow-hooks' own (hooks/mod/paths.ts),
4// copied rather than imported: a plugin is installed alone, and a link into
5// a sibling plugin is broken for every consumer. They must agree, since this
6// mod reads the tasks.json that one writes.
7
8/** CLAUDE_CONFIG_DIR, else ~/.claude; undefined unless absolute. */
9export function configDir(
10  claudeConfigDir: string | undefined,
11  home: string | undefined,
12): string | undefined {
13  const pick =
14    claudeConfigDir || (home ? `${home.replace(/\/+$/, "")}/.claude` : "");
15  if (!pick.startsWith("/")) return undefined;
16  return pick.length > 1 ? pick.replace(/\/+$/, "") : pick;
17}
18
19/** An identifier usable as a single path component (tasks.py's safe). */
20export function safe(value: unknown): string {
21  const chars = [...String(value)].map((c) =>
22    /^[\p{L}\p{N}_-]$/u.test(c) ? c : "-",
23  );
24  return chars.slice(0, 128).join("") || "default";
25}
26
27export const sessionDir = (config: string, session: string): string =>
28  `${config}/kodflow/sessions/${safe(session)}`;
29
30export const tasksFile = (dir: string): string => `${dir}/tasks.json`;
31
32/** Collapse `.`, `..` and repeated slashes of an absolute path. */
33export function normalize(path: string): string {
34  const out: string[] = [];
35  for (const part of path.split("/")) {
36    if (part === "" || part === ".") continue;
37    if (part === "..") out.pop();
38    else out.push(part);
39  }
40  return "/" + out.join("/");
41}
42
43/**
44 * A path as written in a tool call, made absolute: `~` expanded against
45 * `home`, a relative path joined to `base`. Undefined when neither can place
46 * it (a relative path with no base, `~` with no home).
47 */
48export function resolvePath(
49  raw: string,
50  base: string | undefined,
51  home: string | undefined,
52): string | undefined {
53  let path = raw.trim();
54  if (!path) return undefined;
55  if (path === "~" || path.startsWith("~/")) {
56    if (!home) return undefined;
57    path = home + path.slice(1);
58  }
59  if (!path.startsWith("/")) {
60    if (!base || !base.startsWith("/")) return undefined;
61    path = `${base}/${path}`;
62  }
63  return normalize(path);
64}
65
66export const dirname = (path: string): string =>
67  path.lastIndexOf("/") > 0 ? path.slice(0, path.lastIndexOf("/")) : "/";
68
69/** True when `path` is `dir` or lies under it. */
70export const isUnder = (path: string, dir: string): boolean =>
71  path === dir || path.startsWith(dir === "/" ? "/" : dir + "/");
72
73/** `home` shown as `~`, the way a shell prompt spells it. */
74export function tilde(path: string, home: string | undefined): string {
75  if (!home || home === "/") return path;
76  return isUnder(path, home) ? "~" + path.slice(home.length) : path;
77}
78
79/** A file shown relative to the agent's directory when it lies under it. */
80export function shortFile(path: string, dir: string | undefined): string {
81  if (dir && dir !== path && isUnder(path, dir))
82    return path.slice(dir === "/" ? 1 : dir.length + 1);
83  return path;
84}
85
hooks/mod/view.tsx 350 lines
1// view.tsx — the « Agents » pane, from plain data. No `$`: the elements come
2// in from the surface's table (`$.ui.resolve(e)`) and the size from the
3// pane's own props, so a test can draw it at any width. The « Tâches » pane
4// is drawn by layout.tsx (the tree and the graph), in its surface module
5// client.tsx; this one is laid out with the same cells, so its columns line
6// up as the tree's do.
7//
8// The pane is about the subagents: register.tsx opens its tab only while one
9// runs, and its title counts them. The main loop heads the list on one line,
10// in the theme's `claude` colour, so the subagents read against it; each
11// subagent then has its line in its own colour (who.ts, the same as in the
12// tree's agent column): its name, its role (its type, short, or « type
13// inconnu » when nothing said it), its model, and its state, a frozen one
14// in red. Under it, the task it holds and the folder it works in, then the
15// files it last read (violet), wrote (orange) or committed (green).
16//
17//   ─ 2 sous-agents en cours ───────────────────────────────────────────────
18//     AGENT               RÔLE                    MODÈLE      ÉTAT
19//   ◉ principal           fil principal           opus        ● actif · 3 s
20//   ↳ hud-agents          general-purpose         opus·xhigh  ● actif · 15 s
21//       ● #2 Rendu des agents  ·  /w/repo
22//       W src/view.tsx
23
24import type {
25  HudActivity,
26  HudAgent,
27  HudBoard,
28  HudModel,
29  HudOp,
30  HudTask,
31} from "../../types";
32import { MAIN, activityOf } from "./board.ts";
33import { cut, draw, type Cell, type Span } from "./layout.tsx";
34import { modelKey } from "./models.ts";
35import { safe, shortFile, tilde } from "./paths.ts";
36import { modelCell, span as since } from "./tree.ts";
37import { cells } from "./width.ts";
38import {
39  MAIN_COLOR,
40  MAIN_NAME,
41  RUNNING,
42  colorOf,
43  roleOf,
44  running,
45} from "./who.ts";
46
47/** The two elements every surface's table has, all these trees use. */
48export type Els = { Box: any; Text: any };
49
50export const OP_COLOR: Record<HudOp, string> = {
51  read: "#af87ff",
52  write: "#ff8700",
53  commit: "#5faf5f",
54};
55const OP_MARK: Record<HudOp, string> = { read: "R", write: "W", commit: "C" };
56
57/** Under this long since its last call, a loop is « actif ». */
58export const ACTIVE_MS = 60_000;
59/** How long an agent that stopped stays listed, dimmed, under the others. */
60export const AGENT_LINGER_MS = 30 * 60_000;
61
62/** The columns: glyph, name, role, model, then the state takes the rest. */
63export const AW = { glyph: 2, name: 18, role: 22, model: 10, gap: 2 } as const;
64/** Widths from which the role, then the model, keep their column. */
65export const AFULL = { role: 76, model: 56 } as const;
66
67export type AgentsData = {
68  agents: readonly HudAgent[];
69  activity: Readonly<Record<string, HudActivity>>;
70  now: number;
71  home: string | undefined;
72  models?: Readonly<Record<string, HudModel>>;
73  session?: string;
74  board?: HudBoard;
75  /** How long a running loop may go without a call before it is « figé ». */
76  silentMs?: number;
77};
78
79/** HH:MM:SS of a time, local. */
80export const clock = (ms: number): string => {
81  const d = new Date(ms);
82  return [d.getHours(), d.getMinutes(), d.getSeconds()]
83    .map((n) => String(n).padStart(2, "0"))
84    .join(":");
85};
86
87type Tone = Omit<Span, "text">;
88const sp = (text: string, tone: Tone = {}): Span => ({ text, ...tone });
89const dim = (text: string): Span => ({ text, dim: true });
90const cell = (w: number, spans: Span[] = []): Cell => ({
91  w,
92  spans: cut(spans, w),
93});
94
95export type AgentRow = {
96  id: string;
97  name: string;
98  /** The role, its type short; "" when nothing said it. */
99  role: string;
100  color: string;
101  status: string;
102  /** Whether its loop is at work (the tab counts these). */
103  live: boolean;
104  act?: HudActivity;
105  model: string;
106  task?: { label: string; title: string };
107  state: Span[];
108};
109
110/** How a loop stands, in a few words and one colour. */
111function stateOf(
112  status: string,
113  act: HudActivity | undefined,
114  now: number,
115  silentMs: number,
116): Span[] {
117  const age = act ? ` · ${since(now - act.last)}` : "";
118  if (status === "completed") return [dim(`✓ terminé${age}`)];
119  if (status === "failed") return [sp(`✗ échec${age}`, { color: "error" })];
120  if (status === "killed") return [sp(`✗ arrêté${age}`, { color: "error" })];
121  if (status === "idle") return [dim(`◌ au repos${age}`)];
122  if (status === "pending") return [dim("○ démarre")];
123  if (status === "absent") return [dim(`◌ parti${age}`)];
124  if (act && now - act.last >= silentMs)
125    return [sp(`⏸ figé${age}`, { color: "error", bold: true })];
126  if (status === "waiting")
127    return [sp(`◇ attend${age}`, { color: "permission" })];
128  if (act && now - act.last < ACTIVE_MS)
129    return [sp(`● actif${age}`, { color: "success" })];
130  return [sp(`● en cours${age}`, { color: "warning" })];
131}
132
133const sameLoop = (a: string | undefined, b: string) =>
134  !!a && (a === b || safe(a) === safe(b));
135
136/** The task a loop holds: in progress, handed to it, else filed by it. */
137function heldBy(
138  board: HudBoard | undefined,
139  loop: string,
140): HudTask | undefined {
141  const tasks = (board?.epics ?? []).flatMap((e) => e.tasks);
142  const doing = tasks.filter((t) => t.status === "in_progress");
143  return (
144    doing.find((t) => sameLoop(t.workerId, loop)) ??
145    doing.find((t) => !t.workerId && sameLoop(t.agent, loop))
146  );
147}
148
149/**
150 * The loops to list: the main one first, then the subagents at work, most
151 * recently busy first, then the ones that stopped within the last half hour.
152 * A loop no list names (an engine fork, compaction) is not a subagent.
153 */
154export function agentRows(data: AgentsData): AgentRow[] {
155  const silentMs = data.silentMs ?? 10 * 60_000;
156  const model = (loop: string) =>
157    modelCell(data.models?.[modelKey(data.session ?? "", loop)]);
158  const task = (loop: string) => {
159    const t = heldBy(data.board, loop);
160    return t ? { label: t.ref || `#${t.id}`, title: t.subject } : undefined;
161  };
162  const main = data.activity[MAIN];
163  const head: AgentRow = {
164    id: MAIN,
165    name: MAIN_NAME,
166    role: "fil principal",
167    color: MAIN_COLOR,
168    status: "running",
169    live: true,
170    ...(main && { act: main }),
171    model: model(MAIN),
172    state: main ? stateOf("running", main, data.now, Infinity) : [dim("—")],
173  };
174  const t0 = task(MAIN);
175  if (t0) head.task = t0;
176  const subs: AgentRow[] = [];
177  for (const a of data.agents) {
178    const act = activityOf(data.activity, a.id);
179    const live = RUNNING.has(a.status);
180    if (!live && !(act && data.now - act.last < AGENT_LINGER_MS)) continue;
181    const row: AgentRow = {
182      id: a.id,
183      name: a.name || a.label,
184      role: roleOf(a.type),
185      color: colorOf(a.id),
186      status: a.status,
187      live,
188      ...(act && { act }),
189      model: model(a.id),
190      state: stateOf(a.status, act, data.now, silentMs),
191    };
192    const held = task(a.id);
193    if (held) row.task = held;
194    subs.push(row);
195  }
196  subs.sort(
197    (a, b) =>
198      Number(b.live) - Number(a.live) ||
199      (b.act?.last ?? 0) - (a.act?.last ?? 0),
200  );
201  return [head, ...subs];
202}
203
204/** The section's title: how many subagents are at work, never « Agents ». */
205export function agentsTitle(data: AgentsData): string {
206  const n = running(data.agents).length;
207  return n === 0
208    ? "aucun sous-agent en cours"
209    : `${n} sous-agent${n > 1 ? "s" : ""} en cours`;
210}
211
212function nameLine(r: AgentRow, width: number): Cell[] {
213  const faint = r.id !== MAIN && !r.live;
214  const tone: Tone = { color: r.color, ...(faint && { dim: true }) };
215  const glyph = r.id === MAIN ? "◉" : "↳";
216  const out: Cell[] = [
217    cell(AW.glyph, [sp(glyph, tone)]),
218    cell(AW.name, [sp(r.name, { ...tone, bold: r.live })]),
219  ];
220  let used = AW.glyph + AW.name;
221  if (width >= AFULL.role) {
222    out.push(
223      cell(AW.gap),
224      cell(AW.role, [r.role ? dim(r.role) : dim("type inconnu")]),
225    );
226    used += AW.gap + AW.role;
227  }
228  if (width >= AFULL.model) {
229    out.push(
230      cell(AW.gap),
231      cell(AW.model, r.model ? [sp(r.model, { color: "ide" })] : []),
232    );
233    used += AW.gap + AW.model;
234  }
235  const rest = Math.max(0, width - used - AW.gap);
236  out.push(cell(AW.gap), cell(rest, r.state));
237  return out;
238}
239
240/** Under a loop: the task it holds, its folder, and the role when narrow. */
241function whereLine(r: AgentRow, width: number, home: string | undefined) {
242  const spans: Span[] = [];
243  if (width < AFULL.role && r.id !== MAIN)
244    spans.push(dim(r.role || "type inconnu"), dim("  ·  "));
245  if (r.task)
246    spans.push(
247      sp("● ", { color: "warning" }),
248      sp(`${r.task.label} `, { bold: true }),
249      sp(r.task.title),
250    );
251  const dir = r.act?.dir ? tilde(r.act.dir, home) : "";
252  if (dir) spans.push(dim(r.task ? `  ·  ${dir}` : dir));
253  if (!spans.length) return undefined;
254  const pad = 4;
255  return [cell(pad), cell(Math.max(0, width - pad), spans)];
256}
257
258function fileLines(
259  r: AgentRow,
260  width: number,
261  home: string | undefined,
262  count: number,
263): Cell[][] {
264  const pad = 4;
265  return (r.act?.files ?? []).slice(0, count).map((f) => [
266    cell(pad),
267    cell(2, [sp(OP_MARK[f.op], { color: OP_COLOR[f.op] })]),
268    cell(Math.max(0, width - pad - 2), [
269      sp(tilde(shortFile(f.path, r.act?.dir), home), {
270        color: OP_COLOR[f.op],
271      }),
272    ]),
273  ]);
274}
275
276function titleLine(text: string, width: number): Cell[] {
277  const w = Math.min(cells(text), Math.max(0, width - 4));
278  const rest = Math.max(0, width - 2 - w);
279  const line = [cell(2, [dim("─ ")]), cell(w, [sp(text, { bold: true })])];
280  if (rest > 1) line.push(cell(1), cell(rest - 1, [dim("─".repeat(rest - 1))]));
281  return line;
282}
283
284function headerLine(width: number): Cell[] {
285  const out: Cell[] = [cell(AW.glyph), cell(AW.name, [dim("AGENT")])];
286  let used = AW.glyph + AW.name;
287  if (width >= AFULL.role) {
288    out.push(cell(AW.gap), cell(AW.role, [dim("RÔLE")]));
289    used += AW.gap + AW.role;
290  }
291  if (width >= AFULL.model) {
292    out.push(cell(AW.gap), cell(AW.model, [dim("MODÈLE")]));
293    used += AW.gap + AW.model;
294  }
295  out.push(
296    cell(AW.gap),
297    cell(Math.max(0, width - used - AW.gap), [dim("ÉTAT")]),
298  );
299  return out;
300}
301
302/** The lines of the pane, `width` cells by at most `rows`. */
303export function agentsLines(
304  data: AgentsData,
305  width: number,
306  rows: number,
307): Cell[][] {
308  const list = agentRows(data);
309  // What the rows hold, most kept first: each loop's line, then the title,
310  // the task and folder lines, the headers, then the files shared out
311  const fixed = list.length;
312  const title = rows > fixed ? 1 : 0;
313  const where = list.map((r) => whereLine(r, width, data.home));
314  const whereAll = where.filter(Boolean).length;
315  const showWhere = rows >= fixed + title + whereAll;
316  const whereN = showWhere ? whereAll : 0;
317  const header = rows >= fixed + title + whereN + 1 ? 1 : 0;
318  const left = rows - fixed - title - whereN - header;
319  const perAgent = Math.max(0, Math.min(3, Math.floor(left / list.length)));
320  const out: Cell[][] = [];
321  if (title) out.push(titleLine(agentsTitle(data), width));
322  if (header) out.push(headerLine(width));
323  list.forEach((r, i) => {
324    out.push(nameLine(r, width));
325    if (showWhere && where[i]) out.push(where[i]!);
326    out.push(...fileLines(r, width, data.home, perAgent));
327  });
328  return out.slice(0, Math.max(0, rows));
329}
330
331/** The « Agents » pane: the main loop, then each subagent, as cells. */
332export function agentsView(
333  els: Els,
334  data: AgentsData,
335  width: number,
336  rows: number,
337) {
338  return draw(
339    els,
340    {
341      lines: agentsLines(data, width, rows),
342      rowIds: [],
343      tabsLine: -1,
344      tabs: [],
345      order: [],
346    },
347    width,
348  );
349}
350
hooks/mod/who.ts 62 lines
1// who.ts — how a loop is named and coloured, the same in both panes. Pure.
2//
3// The main loop is « principal », drawn in the theme's own `claude` colour
4// (the engine picks its shade for a light or a dark theme). Each subagent
5// keeps one colour for the session, picked from its id: the same agent is
6// the same colour in the « Tâches » column and in the « Agents » pane. The
7// palette stays clear of the colours the tree gives to states (amber under
8// way, teal ready, mauve the person and the epics, green done, red blocked)
9// and of the model's blue, and every shade is a mid tone, legible on a light
10// ground as on a dark one.
11//
12// An agent's role is its type, `$.agent.list()`'s `type` (the Agent call's
13// `subagent_type`), shown short: the plugin prefix dropped, a specialist's
14// « <area>-specialist-<topic> » read « area · topic ». A type nobody gave is
15// left empty, never guessed.
16
17import type { HudAgent } from "../../types";
18import { MAIN } from "./board.ts";
19import { safe } from "./paths.ts";
20
21/** The theme colour of the main loop. */
22export const MAIN_COLOR = "claude";
23export const MAIN_NAME = "principal";
24
25/** One per subagent, stable for its id: mid tones, none a state's. */
26export const AGENT_PALETTE = [
27  "#5f87d7", // steel blue
28  "#d75f87", // rose
29  "#5faf87", // sea green
30  "#af87d7", // lavender
31  "#d7875f", // terracotta
32  "#87af5f", // olive
33  "#d75fd7", // orchid
34  "#5fafd7", // sky
35] as const;
36
37/** Statuses of a subagent whose loop is at work, as `$.agent.list()` says. */
38export const RUNNING = new Set(["pending", "running", "waiting"]);
39
40/** A loop's colour: the theme's for the main one, the palette's otherwise. */
41export function colorOf(loop: string): string {
42  if (loop === MAIN) return MAIN_COLOR;
43  let h = 0;
44  for (const c of safe(loop)) h = (h * 31 + c.codePointAt(0)!) >>> 0;
45  return AGENT_PALETTE[h % AGENT_PALETTE.length]!;
46}
47
48/**
49 * « devops · hashicorp », « general-purpose », « Explore »: an agent type,
50 * short. The plugin's prefix goes, a specialist or executor keeps its area
51 * and its topic. "" when the type is unknown.
52 */
53export function roleOf(type: string | undefined): string {
54  const bare = (type ?? "").trim().replace(/^[\w.-]+:/, "");
55  const m = /^([a-z]+)-(?:specialist|executor)-(.+)$/.exec(bare);
56  return m ? `${m[1]} · ${m[2]}` : bare;
57}
58
59/** The subagents whose loop is at work: what the « Agents » tab counts. */
60export const running = (agents: readonly HudAgent[]): HudAgent[] =>
61  agents.filter((a) => RUNNING.has(a.status));
62
hooks/mod/tree.ts 451 lines
1// tree.ts — the task tree the « Tâches » pane draws, derived from the board.
2// Pure: no `$`, no I/O; what it returns is plain data, handed whole to the
3// pane's surface module (client.tsx) as its props.
4//
5// Nothing here is typed by anyone: the states the tree shows beyond
6// tasks.json's own statuses are read off the links kodflow-hooks keeps
7// (`parent`, `blockedBy`):
8//   ready    ▶ pending, every blocker completed, nobody on it: a task at the
9//              top, or under a parent already completed;
10//   blocked  ⊘ pending, at least one blocker not completed;
11//   todo     ○ pending under a parent not completed: whoever takes the
12//              parent takes it, so it is nobody's to pick up on its own.
13// The critical path is the longest blockedBy chain of tasks not completed,
14// down to the epic's final tasks (the ones nothing waits on); every task on
15// a chain of that length is on it. A list with no link has none.
16
17import type { HudBoard, HudCi, HudModel, HudSeen, HudTask } from "../../types";
18import { MAIN, judge, loopLabel, type Ctx } from "./board.ts";
19import { MAIN_NAME, colorOf, roleOf } from "./who.ts";
20import { cells, fit } from "./width.ts";
21import { modelKey, seenKey } from "./models.ts";
22
23export type NodeState =
24  "done" | "run" | "you" | "wait" | "ready" | "blocked" | "todo";
25
26/** One task as a row of the tree: every cell already worded. */
27export type TreeNode = {
28  id: string;
29  /** How links and footers name it: its ref, else #id. */
30  label: string;
31  title: string;
32  state: NodeState;
33  /** Who works it: a subagent's name, "principal", "toi", "prête"; "" for nobody. */
34  agent: string;
35  /** What `agent` names: the main loop, a subagent, the person, nobody yet. */
36  agentKind: "main" | "sub" | "you" | "ready" | "";
37  /** The subagent's role, its type short (who.ts); "" when unknown. */
38  role: string;
39  /** The loop's colour (who.ts), for the main loop and the subagents. */
40  agentColor?: string;
41  /** model·effort of that loop, "" when the session never said. */
42  model: string;
43  /** Since it last moved; "" for a task nobody has taken. */
44  age: string;
45  silent: boolean;
46  /** "⇠ C6 C7" what it waits on, else "⇢ C9" what it unblocks. */
47  links: string;
48  crit: boolean;
49  desc: string;
50  /** "CI ✓" and the like, for a cited pull request. */
51  ci: string;
52  kids: TreeNode[];
53};
54
55export type TreeEpic = {
56  id: number;
57  title: string;
58  done: number;
59  total: number;
60  age: string;
61  nodes: TreeNode[];
62  /** The critical path's chains, each a list of its tasks, top level first. */
63  chains: { label: string; state: NodeState }[][];
64  /** The task of the person's the epic waits on, and whether on the path. */
65  you?: { label: string; age: string; crit: boolean };
66  /** The first ready task, on the path first. */
67  next?: { label: string; title: string };
68  /** Where the epic ends: its final task on the path, else its last. */
69  goal?: { label: string; title: string; state: NodeState; note: string };
70  /** The graph's edges between top-level tasks, blocker first, by id. */
71  edges: [string, string][];
72};
73
74export type TreeModel = { found: boolean; epics: TreeEpic[] };
75
76export type TreeData = {
77  board: HudBoard;
78  ctx: Ctx;
79  ci: Readonly<Record<string, HudCi>>;
80  models: Readonly<Record<string, HudModel>>;
81  seen: Readonly<Record<string, HudSeen>>;
82  /** The session the board was read for: the `seen` keys carry it. */
83  session: string;
84};
85
86/** "45 s", "12 min", "1 h 20", "12 h", "3 j": six cells at most. */
87export function span(ms: number): string {
88  const s = Math.max(0, Math.floor(ms / 1000));
89  if (s < 60) return `${s} s`;
90  const m = Math.floor(s / 60);
91  if (m < 60) return `${m} min`;
92  const h = Math.floor(m / 60);
93  if (h < 10)
94    return m % 60 ? `${h} h ${String(m % 60).padStart(2, "0")}` : `${h} h`;
95  if (h < 24) return `${h} h`;
96  return `${Math.floor(h / 24)} j`;
97}
98
99const FAMILY = /\b(opus|sonnet|haiku|fable)\b/i;
100const EFFORT: Record<string, [string, string]> = {
101  low: ["low", "lo"],
102  medium: ["med", "med"],
103  high: ["high", "hi"],
104  xhigh: ["xhigh", "xhi"],
105  max: ["max", "max"],
106};
107export const MODEL_CELLS = 10;
108
109/** "opus·xhigh", "sonnet·hi", "haiku": a model id and an effort, in ten cells. */
110export function modelCell(m: HudModel | undefined): string {
111  if (!m?.model) return "";
112  const family = FAMILY.exec(m.model)?.[1]?.toLowerCase() ?? m.model;
113  if (!m.effort) return fit(family, MODEL_CELLS);
114  const [long, short] = EFFORT[m.effort] ?? [m.effort, m.effort];
115  const full = `${family}·${long}`;
116  if (cells(full) <= MODEL_CELLS) return full;
117  return fit(`${family}·${short}`, MODEL_CELLS);
118}
119
120const AGENT_PREFIX = /^[\w.-]+:(developer-)?/;
121export const AGENT_CELLS = 22;
122/** What the role keeps at least beside a long name, in the agent column. */
123const ROLE_MIN = 7;
124
125/** An agent's name, the plugin prefix of a type used as a name dropped. */
126export const agentName = (name: string): string =>
127  name.replace(AGENT_PREFIX, "");
128
129/** A role in the narrow agent column: a specialist's topic, a type's head. */
130export function roleCell(role: string): string {
131  if (!role) return "";
132  const topic = role.split(" · ").pop()!;
133  return topic === "general-purpose" ? "general" : topic;
134}
135
136/**
137 * The agent column's two parts, the name and the role, cut to share `room`
138 * cells around a « · » of three: the role keeps up to seven, the name the
139 * rest; a role with no room for four cells is left out.
140 */
141export function agentParts(
142  name: string,
143  role: string,
144  room: number,
145): { name: string; role: string } {
146  if (!role || cells(name) + 3 + cells(role) <= room)
147    return { name: fit(name, room), role: role && fit(role, room) };
148  const roleW = Math.min(cells(role), ROLE_MIN, room - 3 - 4);
149  if (roleW < 4) return { name: fit(name, room), role: "" };
150  const nameW = Math.min(cells(name), room - 3 - roleW);
151  return { name: fit(name, nameW), role: fit(role, room - 3 - nameW) };
152}
153
154export const LINK_CELLS = 10;
155
156/** "⇠ C6 C7 C8", three labels then "+n", in fourteen cells. */
157export function linkCell(arrow: string, labels: string[]): string {
158  if (!labels.length) return "";
159  const shown = labels.slice(0, 3);
160  const more = labels.length - shown.length;
161  let text = `${arrow} ${shown.join(" ")}${more ? ` +${more}` : ""}`;
162  while (cells(text) > LINK_CELLS && shown.length > 1) {
163    shown.pop();
164    text = `${arrow} ${shown.join(" ")} +${labels.length - shown.length}`;
165  }
166  return fit(text, LINK_CELLS);
167}
168
169const CI_TEXT: Record<string, string> = {
170  pass: "CI ✓",
171  fail: "CI ✗",
172  pending: "CI …",
173  error: "CI ?",
174};
175
176const done = (t: HudTask) => t.status === "completed";
177const labelOf = (t: HudTask) => t.ref || `#${t.id}`;
178
179/** The longest blockedBy chains of unfinished tasks, and who is on them. */
180function criticalPath(
181  tasks: readonly HudTask[],
182  byId: ReadonlyMap<string, HudTask>,
183): { onPath: Set<string>; chains: string[][] } {
184  const open = tasks.filter((t) => !done(t));
185  const openIds = new Set(open.map((t) => t.id));
186  const blockers = (t: HudTask) =>
187    (t.blockedBy ?? []).filter((id) => openIds.has(id) && id !== t.id);
188  const dependents = new Map<string, string[]>();
189  for (const t of open)
190    for (const b of blockers(t))
191      dependents.set(b, [...(dependents.get(b) ?? []), t.id]);
192  // Longest chain from a task down to a final one, and up to a first one;
193  // a cycle in a hand-edited file counts once, never forever
194  const memo = (next: (id: string) => string[]) => {
195    const got = new Map<string, number>();
196    const walk = (id: string, path: Set<string>): number => {
197      const known = got.get(id);
198      if (known !== undefined) return known;
199      if (path.has(id)) return 0;
200      path.add(id);
201      let best = 1;
202      for (const n of next(id)) best = Math.max(best, 1 + walk(n, path));
203      path.delete(id);
204      got.set(id, best);
205      return best;
206    };
207    return (id: string) => walk(id, new Set());
208  };
209  const down = memo((id) => dependents.get(id) ?? []);
210  const up = memo((id) => blockers(byId.get(id)!));
211  let longest = 0;
212  for (const t of open) longest = Math.max(longest, down(t.id));
213  const onPath = new Set<string>();
214  const chains: string[][] = [];
215  if (longest < 2) return { onPath, chains };
216  for (const t of open)
217    if (up(t.id) + down(t.id) - 1 === longest) onPath.add(t.id);
218  // The chains themselves, from each first task along the longest way down
219  const follow = (id: string, left: number, acc: string[]) => {
220    const here = [...acc, id];
221    if (left === 1) return void chains.push(here);
222    for (const n of dependents.get(id) ?? [])
223      if (onPath.has(n) && down(n) === left - 1 && chains.length < 8)
224        follow(n, left - 1, here);
225  };
226  for (const t of open)
227    if (onPath.has(t.id) && up(t.id) === 1) follow(t.id, longest, []);
228  const top = (id: string) => !byId.get(id)?.parent;
229  chains.sort((a, b) => Number(b.every(top)) - Number(a.every(top)));
230  return {
231    onPath,
232    chains,
233  };
234}
235
236/**
237 * The tree of every open epic: tasks under their parent (a parent from
238 * another epic, or none that exists, puts a task at the top), each with its
239 * derived state, its loop, its model, its age and its links.
240 */
241export function buildTree(data: TreeData): TreeModel {
242  const { board, ctx } = data;
243  const epics = board.epics.map((epic) => {
244    const tasks = epic.tasks;
245    const byId = new Map(tasks.map((t) => [t.id, t]));
246    const { onPath, chains: pathIds } = criticalPath(tasks, byId);
247    const unblocks = new Map<string, string[]>();
248    for (const t of tasks)
249      for (const b of t.blockedBy ?? [])
250        unblocks.set(b, [...(unblocks.get(b) ?? []), t.id]);
251    const waitsOn = (t: HudTask) =>
252      (t.blockedBy ?? []).filter((id) => byId.has(id) && !done(byId.get(id)!));
253    const parentOf = (t: HudTask) =>
254      t.parent && t.parent !== t.id ? byId.get(t.parent) : undefined;
255
256    const stateOf = (t: HudTask): NodeState => {
257      if (done(t)) return "done";
258      if (t.status === "in_progress") return "run";
259      // The person's task is theirs whatever its status says
260      if (t.assignee === "human" || t.status === "waiting")
261        return t.assignee === "human" ? "you" : "wait";
262      if (waitsOn(t).length) return "blocked";
263      const parent = parentOf(t);
264      return parent && !done(parent) ? "todo" : "ready";
265    };
266
267    const node = (t: HudTask, depth: number, path: Set<string>): TreeNode => {
268      const state = stateOf(t);
269      const v = judge(t, ctx);
270      const seen = data.seen[seenKey(data.session, t)];
271      const workerId = t.workerId || seen?.workerId;
272      const worker = t.worker || seen?.worker;
273      const loop = workerId || t.agent;
274      let agent = "";
275      let agentKind: TreeNode["agentKind"] = "";
276      let role = "";
277      if (state === "you") {
278        agent = "toi";
279        agentKind = "you";
280      } else if (state === "ready") {
281        agent = "prête";
282        agentKind = "ready";
283      } else if (state === "run" || state === "done" || state === "wait") {
284        // A worker named with no id is still a subagent, known by its name
285        if (loop === MAIN && (!worker || worker === MAIN)) {
286          agent = MAIN_NAME;
287          agentKind = "main";
288        } else {
289          const listed =
290            ctx.agents.find((a) => a.id === loop) ??
291            (loop === MAIN
292              ? ctx.agents.find((a) => a.name === worker)
293              : undefined);
294          agent = agentName(
295            listed?.name || worker || loopLabel(loop, ctx.agents),
296          );
297          role = roleOf(listed?.type);
298          agentKind = "sub";
299        }
300      }
301      const model =
302        state === "run" || state === "done" || state === "wait"
303          ? modelCell(data.models[modelKey(data.session, loop)])
304          : "";
305      let age = "";
306      if (state === "run") age = span(ctx.now - v.last);
307      else if (state === "done" || state === "you" || state === "wait")
308        age = span(ctx.now - t.updated * 1000);
309      const waits = waitsOn(t).map((id) => labelOf(byId.get(id)!));
310      const frees = (unblocks.get(t.id) ?? [])
311        .filter((id) => byId.has(id))
312        .map((id) => labelOf(byId.get(id)!));
313      const links = waits.length ? linkCell("⇠", waits) : linkCell("⇢", frees);
314      const ci =
315        t.prs
316          .map((pr) => CI_TEXT[data.ci[pr]?.state ?? ""] ?? "")
317          .find((c) => c) ?? "";
318      path.add(t.id);
319      const kids =
320        depth < 3
321          ? tasks
322              .filter(
323                (k) =>
324                  parentOf(k) === t && !path.has(k.id) && !topIds.has(k.id),
325              )
326              .map((k) => node(k, depth + 1, path))
327          : [];
328      path.delete(t.id);
329      return {
330        id: t.id,
331        label: labelOf(t),
332        title: t.subject,
333        state,
334        agent,
335        agentKind,
336        role,
337        ...((agentKind === "main" || agentKind === "sub") && {
338          agentColor: colorOf(
339            agentKind === "sub" && loop === MAIN
340              ? (ctx.agents.find((a) => a.name === worker)?.id ??
341                  `name:${worker}`)
342              : loop,
343          ),
344        }),
345        model,
346        age,
347        silent: v.silent,
348        links,
349        crit: onPath.has(t.id),
350        desc: t.description ?? "",
351        ci,
352        kids,
353      };
354    };
355
356    // A parent that is missing, in another epic or in a loop puts the task on top
357    const rooted = (t: HudTask) => {
358      const seen = new Set<string>([t.id]);
359      for (let p = parentOf(t); p; p = parentOf(p)) {
360        if (seen.has(p.id)) return false;
361        seen.add(p.id);
362      }
363      return true;
364    };
365    const tops = tasks.filter((t) => !parentOf(t) || !rooted(t));
366    const topIds = new Set(tops.map((t) => t.id));
367    const nodes = tops.map((t) => node(t, 1, new Set()));
368
369    const topOf = (t: HudTask): HudTask => {
370      const seen = new Set<string>();
371      let cur = t;
372      for (let p = parentOf(cur); p && !seen.has(p.id); p = parentOf(cur)) {
373        seen.add(cur.id);
374        cur = p;
375      }
376      return cur;
377    };
378    const edges: [string, string][] = [];
379    for (const t of tasks)
380      for (const b of t.blockedBy ?? []) {
381        const from = byId.get(b);
382        if (!from) continue;
383        const pair: [string, string] = [topOf(from).id, topOf(t).id];
384        if (
385          pair[0] !== pair[1] &&
386          !edges.some((e) => e[0] === pair[0] && e[1] === pair[1])
387        )
388          edges.push(pair);
389      }
390
391    const flat: TreeNode[] = [];
392    const walk = (list: TreeNode[]) =>
393      list.forEach((n) => (flat.push(n), walk(n.kids)));
394    walk(nodes);
395    const mine = flat.filter((n) => n.state === "you");
396    const youNode = mine.find((n) => n.crit) ?? mine[0];
397    const ready = flat.filter((n) => n.state === "ready");
398    const nextNode =
399      ready.find((n) => n.crit) ??
400      ready.find((n) => tops.some((t) => t.id === n.id)) ??
401      ready[0];
402    const finals = nodes.filter((n) => !edges.some((e) => e[0] === n.id));
403    const goalNode =
404      finals.find((n) => n.crit) ??
405      finals.find((n) => edges.some((e) => e[1] === n.id)) ??
406      nodes[nodes.length - 1];
407    let goal: TreeEpic["goal"];
408    if (goalNode) {
409      const note: Record<NodeState, string> = {
410        done: "fait",
411        run: "en cours",
412        you: "t'attend",
413        wait: "en attente",
414        ready: "prête",
415        blocked: goalNode.links.startsWith("⇠")
416          ? `attend ${goalNode.links.slice(2)}`
417          : "bloquée",
418        todo: "à faire",
419      };
420      goal = {
421        label: goalNode.label,
422        title: goalNode.title,
423        state: goalNode.state,
424        note: note[goalNode.state],
425      };
426    }
427    const doneCount = tasks.filter(done).length;
428    const out: TreeEpic = {
429      id: epic.id,
430      title: epic.title || `#${epic.id}`,
431      done: doneCount,
432      total: tasks.length,
433      age: epic.created ? span(ctx.now - epic.created * 1000) : "",
434      nodes,
435      chains: pathIds.map((c) =>
436        c.map((id) => ({
437          label: labelOf(byId.get(id)!),
438          state: stateOf(byId.get(id)!),
439        })),
440      ),
441      edges,
442    };
443    if (youNode)
444      out.you = { label: youNode.label, age: youNode.age, crit: youNode.crit };
445    if (nextNode) out.next = { label: nextNode.label, title: nextNode.title };
446    if (goal) out.goal = goal;
447    return out;
448  });
449  return { found: board.found, epics };
450}
451
hooks/mod/layout.tsx 1144 lines
1// layout.tsx — the « Tâches » pane's two views, laid out cell by cell.
2// Pure: no `$`. The surface module (client.tsx) draws them with its own
3// elements and keeps the selection; a surface with no `Client` gets the same
4// screen, still, from the hooks module.
5//
6// Alignment is the point. A screen is a list of lines, a line a row of cells
7// of a fixed width each, and every cell becomes a Box of exactly that width:
8// nothing is lined up with padding spaces, and what goes in a cell is cut to
9// it first, measured in terminal cells (width.ts), so a wide glyph never
10// shifts the next column. No cell paints a background: colour is the text's
11// alone, so a transparent terminal shows through everywhere.
12//
13// R12, the tree, at its full width (88 cells of body, 90 with the frame):
14//   margin 2 · tree 30 (3 a level, glyph 2, ref 7, title 17) · agent 22 ·
15//   model·effort 10 · age 6 (right) · links 10, a gap of 2 between them.
16// The agent column names who works the task: « ◉ principal » in the theme's
17// colour, « ↳ name · role » in the subagent's own (who.ts), « toi », « prête ».
18// Narrower, the links go first (under 88), then the model (under 76), then
19// the agent (under 64); the tree and the age stay. On 20 rows: title,
20// headers, the epic, 11 tasks, a rule, the critical path, two lines of
21// detail on the selection and the two lines of the views' bar. Shorter, the
22// finished branches fold into « ✓ fait (n) », then the detail drops to one
23// line, then to none, then the list scrolls with the selection.
24//
25// R4, the graph: three columns of 26 (FAIT · PRÊT, EN COURS · ATTENTE,
26// BLOQUÉ) and two gutters of 5, each top-level task on two rows (its title,
27// then its agent and age). A gutter routes its links on a vertical with
28// ┬ ┐ ┤ ┼ ┘ └ junctions, links sharing no lane on stretches of their own; a
29// link that skips the middle column crosses it on a lane of its own, as a
30// solid line, so no two lines ever cross.
31
32import type { NodeState, TreeEpic, TreeModel, TreeNode } from "./tree.ts";
33import {
34  AGENT_CELLS,
35  LINK_CELLS,
36  MODEL_CELLS,
37  agentParts,
38  roleCell,
39} from "./tree.ts";
40import { cells, fit } from "./width.ts";
41
42/** The elements a screen is drawn with: any surface's table has them. */
43export type Els = { Box: any; Text: any };
44
45// Theme colours, never fixed shades: the engine gives each its tone for the
46// light and the dark themes alike, so the tree reads on either ground.
47export const MAUVE = "merged"; // the epics, the critical path, the views' bar
48export const AMBER = "warning"; // the task under way
49export const TEAL = "planMode"; // ready, nobody on it yet
50export const BLUE = "ide"; // the model
51export const YOU = "permission"; // waiting on the person, as a permission is
52export const GREEN = "success";
53export const RED = "error";
54
55export type Span = {
56  text: string;
57  color?: string;
58  dim?: boolean;
59  bold?: boolean;
60  underline?: boolean;
61};
62
63/** A cell: a fixed width, and either its text or a row of cells inside. */
64export type Cell = { w: number; spans?: Span[]; right?: boolean; row?: Cell[] };
65
66export type Screen = {
67  lines: Cell[][];
68  /** For each line, the task it shows, for a click to select. */
69  rowIds: (string | undefined)[];
70  /** The line of the views' bar, and where each view's name sits. */
71  tabsLine: number;
72  tabs: { x: number; w: number; view: View }[];
73  /** The tasks ↑ and ↓ walk, in the order shown. */
74  order: string[];
75};
76
77export type View = "tree" | "graph";
78
79/** What the person moved: kept by the surface module, across redraws. */
80export type Ui = { view: View; sel?: string; open: string[] };
81
82export const FULL = { links: 88, model: 76, agent: 64 } as const;
83export const W = {
84  margin: 2,
85  tree: 30,
86  agent: AGENT_CELLS,
87  model: MODEL_CELLS,
88  age: 6,
89  links: LINK_CELLS,
90  gap: 2,
91  level: 3,
92  glyph: 2,
93  ref: 7,
94  title: 17,
95} as const;
96
97export const GLYPH: Record<
98  NodeState,
99  { glyph: string; color?: string; dim?: boolean }
100> = {
101  done: { glyph: "✓", color: GREEN },
102  run: { glyph: "●", color: AMBER },
103  you: { glyph: "◇", color: YOU },
104  wait: { glyph: "◇", color: YOU },
105  ready: { glyph: "▶", color: TEAL },
106  blocked: { glyph: "⊘", color: RED },
107  todo: { glyph: "○", dim: true },
108};
109
110const HINT = "↑↓ choisir · ⏎ plier/déplier · tab changer de vue";
111const HINT_SHORT = "↑↓ ⏎ tab";
112
113// --- cells ------------------------------------------------------------------------
114
115const span = (text: string, style: Omit<Span, "text"> = {}): Span => ({
116  text,
117  ...style,
118});
119const dim = (text: string): Span => ({ text, dim: true });
120
121/** Spans cut to `w` cells as one text: the cut falls in the span it reaches. */
122export function cut(spans: Span[], w: number): Span[] {
123  const whole = spans.map((s) => s.text).join("");
124  if (cells(whole) <= w) return spans;
125  const kept = fit(whole, w);
126  const out: Span[] = [];
127  let left = [...kept];
128  for (const s of spans) {
129    if (!left.length) break;
130    const n = [...s.text].length;
131    out.push({ ...s, text: left.slice(0, n).join("") });
132    left = left.slice(n);
133  }
134  if (left.length && out.length) out[out.length - 1]!.text += left.join("");
135  return out;
136}
137
138const cell = (w: number, spans: Span[] = [], right = false): Cell =>
139  right ? { w, spans: cut(spans, w), right } : { w, spans: cut(spans, w) };
140const row = (w: number, inner: Cell[]): Cell => ({ w, row: inner });
141const gap = (): Cell => ({ w: W.gap });
142const rule = (w: number, ch: string): Cell[] => [cell(w, [dim(ch.repeat(w))])];
143
144/** Which of the optional columns a body of `width` cells keeps. */
145export function columnsFor(width: number) {
146  return {
147    links: width >= FULL.links,
148    model: width >= FULL.model,
149    agent: width >= FULL.agent,
150    tree: Math.max(8, Math.min(W.tree, width - W.margin - W.gap - W.age)),
151  };
152}
153
154// --- the tree's rows ----------------------------------------------------------------
155
156type Visible = {
157  node: TreeNode;
158  depth: number;
159  lasts: boolean[];
160  top: TreeNode;
161};
162
163/** The rows the tree shows, an open task's subtasks under it. */
164export function visible(nodes: TreeNode[], open: readonly string[]): Visible[] {
165  const out: Visible[] = [];
166  const walk = (
167    list: TreeNode[],
168    depth: number,
169    lasts: boolean[],
170    top?: TreeNode,
171  ) =>
172    list.forEach((node, i) => {
173      const here = [...lasts, i === list.length - 1];
174      out.push({ node, depth, lasts: here, top: top ?? node });
175      if (node.kids.length && open.includes(node.id))
176        walk(node.kids, depth + 1, here, top ?? node);
177    });
178  walk(nodes, 0, []);
179  return out;
180}
181
182const findNode = (model: TreeModel, id: string | undefined) => {
183  let hit:
184    | { node: TreeNode; epic: TreeEpic; top: TreeNode; parent?: TreeNode }
185    | undefined;
186  for (const epic of model.epics) {
187    const walk = (list: TreeNode[], top?: TreeNode, parent?: TreeNode) =>
188      list.forEach((n) => {
189        if (n.id === id)
190          hit = parent
191            ? { node: n, epic, top: top!, parent }
192            : { node: n, epic, top: n };
193        walk(n.kids, top ?? n, n);
194      });
195    walk(epic.nodes);
196  }
197  return hit;
198};
199
200/**
201 * Where the person starts: the first task under way, its branch open down to
202 * the running subtask; else the first task left to do; else the first.
203 */
204export function initialUi(model: TreeModel): Ui {
205  for (const epic of model.epics)
206    for (const top of epic.nodes) {
207      if (top.state !== "run") continue;
208      const open: string[] = [];
209      for (let n: TreeNode | undefined = top; n?.kids.length;) {
210        open.push(n.id);
211        n = n.kids.find((k) => k.state === "run" && k.kids.length);
212      }
213      return { view: "tree", sel: top.id, open };
214    }
215  const tops = model.epics.flatMap((e) => e.nodes);
216  const first = tops.find((n) => n.state !== "done") ?? tops[0];
217  return first
218    ? { view: "tree", sel: first.id, open: [] }
219    : { view: "tree", open: [] };
220}
221
222/** ⏎ on a task: fold or unfold it; one top-level branch open at a time. */
223export function toggle(model: TreeModel, ui: Ui, id: string): Ui {
224  const hit = findNode(model, id);
225  if (!hit || !hit.node.kids.length) return ui;
226  if (ui.open.includes(id))
227    return { ...ui, open: ui.open.filter((o) => o !== id) };
228  const tops = new Set(model.epics.flatMap((e) => e.nodes.map((n) => n.id)));
229  const open = tops.has(id) ? ui.open.filter((o) => !tops.has(o)) : ui.open;
230  return { ...ui, open: [...open, id] };
231}
232
233/** ↑ or ↓: the previous or next task shown, at the ends it stays. */
234export function step(screen: Screen, ui: Ui, by: number): Ui {
235  const i = ui.sel === undefined ? -1 : screen.order.indexOf(ui.sel);
236  const next =
237    screen.order[Math.max(0, Math.min(screen.order.length - 1, i + by))];
238  return next === undefined ? ui : { ...ui, sel: next };
239}
240
241function treeCell(
242  v: Visible,
243  width: number,
244  selected: boolean,
245  open: boolean,
246): Cell {
247  const { node, depth } = v;
248  const inner: Cell[] = [];
249  // The ancestors' lines, then this task's own corner
250  for (const last of v.lasts.slice(0, -1))
251    inner.push(cell(W.level, [dim(last ? "" : "│")]));
252  const corner = v.lasts[v.lasts.length - 1] ? "└" : "├";
253  const mark = node.kids.length ? (open ? "▾" : "▸") : "─";
254  inner.push(
255    cell(W.level, [
256      dim(corner),
257      node.kids.length ? span(mark, { color: MAUVE }) : dim(mark),
258    ]),
259  );
260  const g = GLYPH[node.state];
261  inner.push(
262    cell(W.glyph, [
263      span(g.glyph, {
264        ...(g.color && { color: g.color }),
265        ...(g.dim && { dim: true }),
266      }),
267    ]),
268  );
269  const used = W.level * (depth + 1) + W.glyph;
270  const strong = node.state === "run";
271  const faint = node.state === "done" || node.state === "blocked";
272  if (depth === 0) {
273    inner.push(
274      cell(W.ref, [
275        span(node.label, strong ? { bold: true } : faint ? { dim: true } : {}),
276      ]),
277    );
278  }
279  const left = width - used - (depth === 0 ? W.ref : 0);
280  inner.push(
281    cell(Math.max(0, Math.min(W.title, left)), [
282      span(node.title, {
283        ...(strong && { bold: true }),
284        ...(faint && { dim: true }),
285        ...(selected && { underline: true }),
286      }),
287    ]),
288  );
289  return row(width, inner);
290}
291
292type Cols = ReturnType<typeof columnsFor>;
293
294/**
295 * Who works a task, in `w` cells: « ◉ principal » in the theme's colour,
296 * « ↳ name · role » in the subagent's own, the person « toi », « prête »
297 * when nobody took it yet. A finished task's is dimmed.
298 */
299export function agentSpans(n: TreeNode, w: number): Span[] {
300  if (!n.agent) return [];
301  const faint = n.state === "done";
302  const tone = (color: string | undefined): Omit<Span, "text"> => ({
303    ...(color && { color }),
304    ...(faint && { dim: true }),
305  });
306  switch (n.agentKind) {
307    case "you":
308      return [span(n.agent, tone(YOU))];
309    case "ready":
310      return [span(n.agent, tone(TEAL))];
311    case "main":
312      return [
313        span("◉ ", tone(n.agentColor)),
314        span(n.agent, tone(n.agentColor)),
315      ];
316    case "sub": {
317      const p = agentParts(n.agent, roleCell(n.role), Math.max(0, w - 2));
318      return [
319        span("↳ ", tone(n.agentColor)),
320        span(p.name, tone(n.agentColor)),
321        ...(p.role ? [dim(" · "), dim(p.role)] : []),
322      ];
323    }
324    default:
325      return [span(n.agent, faint ? { dim: true } : {})];
326  }
327}
328
329function taskLine(
330  v: Visible,
331  cols: Cols,
332  selected: boolean,
333  open: boolean,
334): Cell[] {
335  const n = v.node;
336  const out: Cell[] = [
337    cell(W.margin, n.crit ? [span("┃", { color: MAUVE })] : []),
338    treeCell(v, cols.tree, selected, open),
339  ];
340  if (cols.agent) out.push(gap(), cell(W.agent, agentSpans(n, W.agent)));
341  if (cols.model)
342    out.push(
343      gap(),
344      cell(
345        W.model,
346        n.model
347          ? [
348              span(
349                n.model,
350                n.state === "done" ? { dim: true } : { color: BLUE },
351              ),
352            ]
353          : [],
354      ),
355    );
356  const ageTone = n.silent
357    ? { color: RED, bold: true }
358    : n.state === "run"
359      ? { color: AMBER }
360      : n.state === "you" || n.state === "wait"
361        ? { color: YOU }
362        : { dim: true };
363  out.push(gap(), cell(W.age, n.age ? [span(n.age, ageTone)] : [], true));
364  if (cols.links)
365    out.push(
366      gap(),
367      cell(
368        W.links,
369        n.links
370          ? [
371              span(
372                n.links,
373                n.state === "blocked" ? { color: RED } : { dim: true },
374              ),
375            ]
376          : [],
377      ),
378    );
379  return out;
380}
381
382function headerLine(cols: Cols): Cell[] {
383  const hd = (t: string) => [dim(t)];
384  const out: Cell[] = [cell(W.margin), cell(cols.tree, hd("TÂCHE"))];
385  if (cols.agent) out.push(gap(), cell(W.agent, hd("AGENT")));
386  if (cols.model) out.push(gap(), cell(W.model, hd("MODÈLE")));
387  out.push(gap(), cell(W.age, hd("ÂGE"), true));
388  if (cols.links) out.push(gap(), cell(W.links, hd("LIENS")));
389  return out;
390}
391
392/** « 3/5 », done over total, in green once every task is done. */
393const progress = (epic: TreeEpic): Span =>
394  epic.total && epic.done === epic.total
395    ? span(`${epic.done}/${epic.total}`, { color: GREEN })
396    : dim(`${epic.done}/${epic.total}`);
397
398/**
399 * An epic's line: ◆, its number and title, its progress right after the
400 * title, its age. The agent and model columns stay empty: they speak of the
401 * tasks. The tasks filed under no epic sit under a quiet « Hors épopée »,
402 * with no number, no progress and no age.
403 */
404function epicLine(epic: TreeEpic, cols: Cols): Cell[] {
405  // The title runs over the agent and model columns, empty on this line
406  const span2 =
407    cols.tree +
408    (cols.agent ? W.gap + W.agent : 0) +
409    (cols.model ? W.gap + W.model : 0);
410  const titleW = Math.max(0, span2 - W.glyph - W.ref);
411  const loose = epic.id === 0;
412  const count = loose ? undefined : progress(epic);
413  const countW = count ? cells(count.text) + 2 : 0;
414  const nameW = Math.max(
415    0,
416    Math.min(cells(epic.title), titleW - (titleW > countW + 4 ? countW : 0)),
417  );
418  const out: Cell[] = [
419    cell(W.margin),
420    row(span2, [
421      cell(W.glyph, [loose ? dim("◌") : span("◆", { color: MAUVE })]),
422      cell(
423        W.ref,
424        loose ? [] : [span(`#${epic.id}`, { color: MAUVE, bold: true })],
425      ),
426      cell(nameW, [
427        loose
428          ? dim(epic.title)
429          : span(epic.title, { color: MAUVE, bold: true }),
430      ]),
431      cell(
432        Math.max(0, titleW - nameW),
433        count && titleW - nameW >= countW ? [dim("  "), count] : [],
434      ),
435    ]),
436  ];
437  out.push(gap(), cell(W.age, loose ? [] : [dim(epic.age)], true));
438  if (cols.links) out.push(gap(), cell(W.links));
439  return out;
440}
441
442function foldLine(count: number, cols: Cols): Cell[] {
443  return [
444    cell(W.margin),
445    cell(cols.tree, [
446      dim("└─ "),
447      span("✓", { color: GREEN }),
448      dim(` fait (${count})`),
449    ]),
450  ];
451}
452
453const plural = (n: number, one: string, many: string) =>
454  `${n} ${n > 1 ? many : one}`;
455
456/**
457 * What the section is about, never the tab's own name again: the one epic
458 * open (« #1 Recette kodflow »), else how many (« 2 épopées ouvertes »),
459 * then the tasks under no epic (« 1 tâche hors épopée »).
460 */
461export function sectionTitle(
462  model: TreeModel,
463  epic: TreeEpic | undefined,
464): string {
465  const real = model.epics.filter((e) => e.id !== 0);
466  const loose = model.epics.find((e) => e.id === 0);
467  const left = loose ? loose.total - loose.done : 0;
468  const parts: string[] = [];
469  if (real.length === 1 && !left) {
470    const one = epic && epic.id !== 0 ? epic : real[0]!;
471    parts.push(`#${one.id} ${one.title}`);
472  } else if (real.length)
473    parts.push(plural(real.length, "épopée ouverte", "épopées ouvertes"));
474  if (left) parts.push(plural(left, "tâche hors épopée", "tâches hors épopée"));
475  return parts.join(" · ");
476}
477
478function titleLine(
479  model: TreeModel,
480  epic: TreeEpic | undefined,
481  width: number,
482): Cell[] {
483  const what = sectionTitle(model, epic);
484  const head: Cell[] = [cell(2, [dim("─ ")])];
485  const used = 2;
486  const wWhat = Math.min(cells(what), Math.max(0, width - used - 2));
487  head.push(cell(wWhat, [span(what, { color: MAUVE })]));
488  const rest = Math.max(0, width - used - wWhat);
489  if (rest > 1) head.push(cell(1), cell(rest - 1, [dim("─".repeat(rest - 1))]));
490  return head;
491}
492
493const stateTone = (s: NodeState): Omit<Span, "text"> => {
494  const g = GLYPH[s];
495  return g.color ? { color: g.color } : { dim: true };
496};
497
498function chainSpans(epic: TreeEpic | undefined): Span[] {
499  if (!epic?.chains.length) return [dim("aucun lien")];
500  const out: Span[] = [];
501  epic.chains.forEach((chain, i) => {
502    if (i) out.push(dim("  ·  "));
503    chain.forEach((t, j) => {
504      if (j) out.push(dim(" → "));
505      out.push(span(t.label, stateTone(t.state)));
506    });
507  });
508  return out;
509}
510
511/** "┃ chemin critique  C6 → C9 · C7 → C9", and who it waits on, right. */
512function critLine(epic: TreeEpic | undefined, width: number): Cell[] {
513  const head = [
514    span("┃ ", { color: MAUVE }),
515    span("chemin critique  ", { color: MAUVE }),
516  ];
517  const you = epic?.you;
518  const tail: Span[] = you
519    ? you.crit
520      ? [dim("bloqué par "), span(`toi (${you.label})`, { color: YOU })]
521      : [
522          dim("t'attend "),
523          span(`◇ ${you.label}`, { color: YOU }),
524          dim(you.age ? ` · ${you.age}` : ""),
525        ]
526    : [];
527  const headW = Math.min(19, width);
528  // The chains keep 8 cells before who waits is given any, and the gap of 2
529  const fits = width - headW - 8 - W.gap;
530  const tailW =
531    fits > 0 ? Math.min(cells(tail.map((s) => s.text).join("")), fits) : 0;
532  const midW = Math.max(0, width - headW - tailW - (tailW ? 2 : 0));
533  const out: Cell[] = [cell(headW, head), cell(midW, chainSpans(epic))];
534  if (tailW) out.push(cell(2), cell(tailW, tail));
535  return out;
536}
537
538function detailLines(
539  model: TreeModel,
540  ui: Ui,
541  width: number,
542  count: number,
543): Cell[][] {
544  if (count <= 0) return [];
545  const hit = findNode(model, ui.sel);
546  if (!hit)
547    return [[cell(width, [dim("Aucune tâche choisie")])], [cell(width)]].slice(
548      0,
549      count,
550    );
551  const n = hit.node;
552  const g = GLYPH[n.state];
553  const name = hit.parent ? n.title : `${n.label}  ${n.title}`;
554  const meta = [
555    n.role ? `${n.agent} · ${n.role}` : n.agent,
556    n.model,
557    n.age,
558    n.links,
559    n.ci,
560    n.silent ? "figé" : "",
561  ]
562    .filter(Boolean)
563    .join(" · ");
564  const first: Span[] = [
565    span(`${g.glyph} `, g.color ? { color: g.color } : { dim: true }),
566    span(name, { bold: true }),
567  ];
568  if (meta) first.push(dim(`   ${meta}`));
569  if (hit.parent) first.push(dim(`  sous-tâche de ${hit.top.label}`));
570  const kids = n.kids.length
571    ? `${n.kids.length} sous-tâche${n.kids.length > 1 ? "s" : ""}${
572        ui.open.includes(n.id)
573          ? ""
574          : `, repliée${n.kids.length > 1 ? "s" : ""} (⏎ ou clic)`
575      }`
576    : "";
577  const second = n.desc || kids || "Aucune description";
578  const lines = [
579    [cell(width, first)],
580    [cell(W.gap), cell(Math.max(0, width - W.gap), [dim(second)])],
581  ];
582  return lines.slice(0, count);
583}
584
585function tabsLines(
586  view: View,
587  width: number,
588): { lines: Cell[][]; tabs: Screen["tabs"] } {
589  const on = (v: View): Omit<Span, "text"> =>
590    v === view ? { color: MAUVE, bold: true, underline: true } : { dim: true };
591  const hint = width >= 64 ? HINT : HINT_SHORT;
592  const hintW = Math.min(cells(hint), Math.max(0, width - 15));
593  const line: Cell[] = [
594    cell(5, [span("Arbre", on("tree"))]),
595    cell(3),
596    cell(6, [span("Graphe", on("graph"))]),
597  ];
598  const rest = Math.max(0, width - 14 - hintW);
599  if (rest) line.push(cell(rest));
600  if (hintW) line.push(cell(hintW, [dim(hint)]));
601  return {
602    lines: [rule(width, "─"), line],
603    tabs: [
604      { x: 0, w: 5, view: "tree" },
605      { x: 8, w: 6, view: "graph" },
606    ],
607  };
608}
609
610// --- R12 ---------------------------------------------------------------------------------
611
612type Body = { line: Cell[]; id?: string; selectable?: string };
613
614/** The tree view, `width` cells by `rows` lines. */
615export function treeScreen(
616  model: TreeModel,
617  ui: Ui,
618  width: number,
619  rows: number,
620): Screen {
621  const cols = columnsFor(width);
622  const sel = findNode(model, ui.sel);
623  const epic = sel?.epic ?? model.epics[0];
624
625  const bodyOf = (foldDone: boolean): Body[] => {
626    const out: Body[] = [];
627    for (const e of model.epics) {
628      out.push({ line: epicLine(e, cols) });
629      const rows = visible(e.nodes, ui.open);
630      const finished = foldDone
631        ? e.nodes.filter((n) => n.state === "done" && n.id !== sel?.top.id)
632        : [];
633      const folded = new Set(finished.map((n) => n.id));
634      let foldShown = false;
635      for (const v of rows) {
636        if (folded.has(v.top.id)) {
637          if (!foldShown) out.push({ line: foldLine(finished.length, cols) });
638          foldShown = true;
639          continue;
640        }
641        const id = v.node.id;
642        out.push({
643          line: taskLine(v, cols, id === ui.sel, ui.open.includes(id)),
644          id,
645          selectable: id,
646        });
647      }
648    }
649    return out;
650  };
651
652  // What the rows hold, most kept first: the folds, then the detail, then
653  // the bar, the path and the headers give way, and the list scrolls
654  let detail = 2;
655  let bar = 2;
656  let foot = 2; // the rule and the critical path
657  let header = 1;
658  let title = 1;
659  const room = () => rows - title - header - foot - detail - bar;
660  let body = bodyOf(false);
661  if (body.length > room()) body = bodyOf(true);
662  while (body.length > room() && detail > 0) detail--;
663  while (room() < 1 + model.epics.length && bar > 0) bar = 0;
664  while (room() < 1 + model.epics.length && foot > 0) foot = 0;
665  if (room() < 2 && header) header = 0;
666  if (room() < 2 && title) title = 0;
667
668  const space = Math.max(0, room());
669  let shown = body;
670  if (body.length > space) {
671    const at = Math.max(
672      0,
673      body.findIndex((b) => b.id === ui.sel),
674    );
675    const start = Math.max(
676      0,
677      Math.min(body.length - space, at - Math.floor(space / 2)),
678    );
679    shown = body.slice(start, start + space);
680  }
681
682  const lines: Cell[][] = [];
683  const rowIds: (string | undefined)[] = [];
684  const push = (line: Cell[], id?: string) => {
685    lines.push(line);
686    rowIds.push(id);
687  };
688  if (title) push(titleLine(model, epic, width));
689  if (header) push(headerLine(cols));
690  for (const b of shown) push(b.line, b.id);
691  while (lines.length < title + header + space) push([cell(width)]);
692  if (foot) {
693    push(rule(width, "┄"));
694    push(critLine(epic, width));
695  }
696  for (const d of detailLines(model, ui, width, detail)) push(d);
697  let tabsLine = -1;
698  let tabs: Screen["tabs"] = [];
699  if (bar) {
700    const t = tabsLines("tree", width);
701    push(t.lines[0]!);
702    tabsLine = lines.length;
703    push(t.lines[1]!);
704    tabs = t.tabs;
705  }
706  return {
707    lines,
708    rowIds,
709    tabsLine,
710    tabs,
711    order: body.filter((b) => b.selectable).map((b) => b.selectable!),
712  };
713}
714
715// --- R4 -----------------------------------------------------------------------------------
716
717const COLUMN: Record<NodeState, 0 | 1 | 2> = {
718  done: 0,
719  ready: 0,
720  run: 1,
721  you: 1,
722  wait: 1,
723  blocked: 2,
724  todo: 2,
725};
726
727export type Lanes = {
728  /** Lane of each top-level task, by id, and its column. */
729  at: Map<string, { col: 0 | 1 | 2; lane: number }>;
730  count: number;
731  /** The edges drawn: left column to a column on its right. */
732  edges: [string, string][];
733};
734
735/**
736 * Lanes for the graph's top-level tasks. The middle column takes the first
737 * lanes; on the left, the tasks feeding only the middle come first, those
738 * feeding the right one after every lane the middle uses (so their line
739 * crosses the middle on a free lane), the ones that also feed the middle
740 * before those that do not; on the right, each task at its sources'
741 * middle lane, or the next one free.
742 */
743export function lanesOf(epic: TreeEpic): Lanes {
744  const col = new Map(epic.nodes.map((n) => [n.id, COLUMN[n.state]]));
745  const edges = epic.edges.filter(
746    ([a, b]) => (col.get(a) ?? 0) < (col.get(b) ?? 0),
747  );
748  const at = new Map<string, { col: 0 | 1 | 2; lane: number }>();
749  const inCol = (c: number) => epic.nodes.filter((n) => col.get(n.id) === c);
750  const mid = inCol(1);
751  mid.forEach((n, lane) => at.set(n.id, { col: 1, lane }));
752  const to = (id: string, c: number) =>
753    edges.some(([a, b]) => a === id && col.get(b) === c);
754  const left = inCol(0);
755  const feedsMid = left.filter((n) => !to(n.id, 2));
756  const firstTarget = (id: string) =>
757    Math.min(
758      ...edges
759        .filter(([a]) => a === id)
760        .map(([, b]) => at.get(b)?.lane ?? Infinity),
761      Infinity,
762    );
763  feedsMid.sort((a, b) => firstTarget(a.id) - firstTarget(b.id));
764  feedsMid.forEach((n, lane) => at.set(n.id, { col: 0, lane }));
765  const skips = left.filter((n) => to(n.id, 2));
766  skips.sort((a, b) => Number(to(b.id, 1)) - Number(to(a.id, 1)));
767  const base = Math.max(feedsMid.length, mid.length);
768  skips.forEach((n, i) => at.set(n.id, { col: 0, lane: base + i }));
769  const taken = new Set<number>();
770  for (const n of inCol(2)) {
771    const sources = edges
772      .filter(([, b]) => b === n.id)
773      .map(([a]) => at.get(a)?.lane ?? 0);
774    // At its sources' middle lane, so its line splits them evenly
775    sources.sort((x, y) => x - y);
776    let lane = sources.length ? sources[Math.floor(sources.length / 2)]! : 0;
777    while (taken.has(lane)) lane++;
778    taken.add(lane);
779    at.set(n.id, { col: 2, lane });
780  }
781  let count = 0;
782  for (const v of at.values()) count = Math.max(count, v.lane + 1);
783  return { at, count, edges };
784}
785
786/** The junction for a row of a gutter's vertical. */
787function junction(
788  left: boolean,
789  right: boolean,
790  up: boolean,
791  down: boolean,
792): string {
793  const key = `${+left}${+right}${+up}${+down}`;
794  const table: Record<string, string> = {
795    "1111": "┼",
796    "1101": "┬",
797    "1110": "┴",
798    "1001": "┐",
799    "1010": "┘",
800    "0101": "┌",
801    "0110": "└",
802    "1011": "┤",
803    "0111": "├",
804    "1100": "─",
805    "0011": "│",
806    "1000": "─",
807    "0100": "─",
808  };
809  return table[key] ?? "";
810}
811
812type Gutter = { cells: Cell[][] };
813
814/**
815 * The vertical spans of a gutter: the lanes its links join, grouped so that
816 * links sharing no lane get a span each; spans that would overlap are one
817 * (they share the vertical, which is a merge, never a crossing).
818 */
819function spans(links: readonly [number, number][]): [number, number][] {
820  let out: [number, number][] = links.map(([a, b]) => [
821    Math.min(a, b),
822    Math.max(a, b),
823  ]);
824  out.sort((x, y) => x[0] - y[0]);
825  const merged: [number, number][] = [];
826  for (const s of out) {
827    const last = merged[merged.length - 1];
828    if (last && s[0] <= last[1]) last[1] = Math.max(last[1], s[1]);
829    else merged.push([s[0], s[1]]);
830  }
831  return merged;
832}
833
834/**
835 * One gutter's cells, two per lane: entries from the left (a task feeding the
836 * next column, or a line crossing the middle), exits to the right (a task
837 * fed), each group of links on its own stretch of the vertical.
838 */
839function gutter(
840  lanes: number,
841  links: readonly [number, number][],
842  passes: Set<number>,
843): Gutter {
844  const enters = new Set(links.map(([a]) => a));
845  const exits = new Set(links.map(([, b]) => b));
846  const stretches = spans(links);
847  const within = (lane: number) =>
848    stretches.find(([t, b]) => lane >= t && lane <= b);
849  const out: Cell[][] = [];
850  for (let lane = 0; lane < lanes; lane++) {
851    const l = enters.has(lane);
852    const r = exits.has(lane);
853    const p = passes.has(lane);
854    const span = within(lane);
855    const up = !!span && lane > span[0];
856    const down = !!span && lane < span[1];
857    // A line crossing on to the next column leaves the junction to the right
858    const j =
859      l || r ? junction(l, r || p, up, down) : p ? "─" : up && down ? "│" : "";
860    const tail = r ? "─▶" : p ? "───" : "";
861    out.push([
862      cell(1, l || p ? [dim("─")] : []),
863      cell(1, j ? [dim(j)] : []),
864      cell(3, tail ? [dim(tail)] : []),
865    ]);
866    out.push([cell(1), cell(1, down ? [dim("│")] : []), cell(3)]);
867  }
868  return { cells: out };
869}
870
871function graphNode(n: TreeNode, w: number, fills: boolean): Cell[][] {
872  const g = GLYPH[n.state];
873  const strong = n.state === "run";
874  const idW = 6;
875  const titleW = Math.min(
876    cells(fit(n.title, Math.max(0, w - W.glyph - idW))),
877    Math.max(0, w - W.glyph - idW),
878  );
879  const fillW = Math.max(0, w - W.glyph - idW - titleW);
880  const first: Cell[] = [
881    cell(W.glyph, [
882      span(g.glyph, g.color ? { color: g.color } : { dim: true }),
883    ]),
884    cell(idW, [span(n.label, strong ? { bold: true } : {})]),
885    cell(titleW, [span(n.title, strong ? { bold: true } : {})]),
886  ];
887  if (fillW)
888    first.push(
889      cell(fillW, fills && fillW > 1 ? [dim(` ${"─".repeat(fillW - 1)}`)] : []),
890    );
891  const meta: Span[] =
892    n.state === "ready"
893      ? [span("prête", { color: TEAL }), dim(" · personne dessus")]
894      : n.state === "you"
895        ? [
896            span("attend toi", { color: YOU }),
897            ...(n.age ? [dim(" · "), span(n.age, { color: YOU })] : []),
898          ]
899        : n.state === "blocked"
900          ? [
901              span(
902                n.links.startsWith("⇠")
903                  ? `attend ${n.links.slice(2)}`
904                  : "bloquée",
905                { color: RED },
906              ),
907            ]
908          : [
909              ...(n.agent
910                ? [
911                    span(n.agent, {
912                      ...(n.agentColor && { color: n.agentColor }),
913                      ...(n.state === "done" && { dim: true }),
914                    }),
915                  ]
916                : []),
917              // The model only where the age still fits after it
918              ...(n.model &&
919              n.state === "done" &&
920              cells(`${n.agent} · ${n.model} · ${n.age}`) <= w - W.gap
921                ? [dim(` · ${n.model}`)]
922                : []),
923              ...(n.age
924                ? [
925                    dim(n.agent ? " · " : ""),
926                    span(
927                      n.age,
928                      n.state === "run" ? { color: AMBER } : { dim: true },
929                    ),
930                  ]
931                : []),
932            ];
933  return [
934    [row(w, first)],
935    [row(w, [cell(W.gap), cell(Math.max(0, w - W.gap), meta)])],
936  ];
937}
938
939/** The graph view, `width` cells by `rows` lines. */
940export function graphScreen(
941  model: TreeModel,
942  ui: Ui,
943  width: number,
944  rows: number,
945): Screen {
946  const sel = findNode(model, ui.sel);
947  const epic = sel?.epic ?? model.epics[0];
948  const colW = width >= FULL.links ? 26 : Math.floor((width - 10) / 3);
949  // Under 40 cells a node no longer holds its glyph, its ref and a title
950  const fits = colW >= 10;
951  const lines: Cell[][] = [];
952  const push = (l: Cell[]) => lines.push(l);
953  push(titleLine(model, epic, width));
954  push(
955    fits
956      ? [
957          cell(colW, [dim("FAIT · PRÊT")]),
958          cell(5),
959          cell(colW, [dim("EN COURS · ATTENTE")]),
960          cell(5),
961          cell(colW, [dim("BLOQUÉ")]),
962        ]
963      : [cell(width, [dim("le graphe veut 40 colonnes")])],
964  );
965  const footer = 6; // rule, three lines, the bar's two
966  const room = Math.max(0, rows - 2 - footer);
967  if (epic && fits) {
968    const { at, count, edges } = lanesOf(epic);
969    const byLane: (TreeNode | undefined)[][] = [[], [], []];
970    for (const n of epic.nodes) {
971      const p = at.get(n.id)!;
972      byLane[p.col]![p.lane] = n;
973    }
974    const colOf = (id: string) => at.get(id)?.col;
975    const laneOf = (id: string) => at.get(id)!.lane;
976    const lanePair = ([a, b]: [string, string]): [number, number] => [
977      laneOf(a),
978      laneOf(b),
979    ];
980    const links0 = edges
981      .filter(([a, b]) => colOf(a) === 0 && colOf(b) === 1)
982      .map(lanePair);
983    // A link from the left column to the right one crosses the middle on its
984    // source's lane, and joins the second gutter there
985    const links1 = edges.filter(([, b]) => colOf(b) === 2).map(lanePair);
986    const skipping = new Set(
987      edges
988        .filter(([a, b]) => colOf(a) === 0 && colOf(b) === 2)
989        .map(([a]) => laneOf(a)),
990    );
991    const g0 = gutter(count, links0, skipping);
992    const g1 = gutter(count, links1, new Set());
993    const fills = (id: string) => edges.some(([a]) => a === id);
994    const lanesShown = Math.min(count, Math.floor(room / 2));
995    for (let lane = 0; lane < lanesShown; lane++) {
996      const parts = [0, 1, 2].map((c) => {
997        const n = byLane[c]![lane];
998        if (n) return graphNode(n, colW, fills(n.id));
999        // A line crossing the middle column on a free lane
1000        if (c === 1 && skipping.has(lane))
1001          return [[cell(colW, [dim("─".repeat(colW))])], [cell(colW)]];
1002        return [[cell(colW)], [cell(colW)]];
1003      });
1004      for (const r of [0, 1])
1005        push([
1006          ...parts[0]![r]!,
1007          row(5, g0.cells[lane * 2 + r]!),
1008          ...parts[1]![r]!,
1009          row(5, g1.cells[lane * 2 + r]!),
1010          ...parts[2]![r]!,
1011        ]);
1012    }
1013    if (count > lanesShown && lines.length > 2) {
1014      lines.pop();
1015      push([
1016        cell(width, [dim(`… ${count - lanesShown + 1} couloir(s) de plus`)]),
1017      ]);
1018    }
1019  }
1020  while (lines.length < 2 + room) push([cell(width)]);
1021  push(rule(width, "┄"));
1022  // Footer: next action and who waits on you, the path, the deliverable
1023  const next: Span[] = [dim("prochaine action  ")];
1024  if (epic?.next)
1025    next.push(
1026      span(`▶ ${epic.next.label}`, { color: TEAL }),
1027      dim(" prête, personne dessus"),
1028    );
1029  else next.push(dim("rien de prêt"));
1030  const you: Span[] = epic?.you
1031    ? [
1032        dim("t'attend  "),
1033        span(`◇ ${epic.you.label}`, { color: YOU }),
1034        dim(epic.you.age ? ` · ${epic.you.age}` : ""),
1035      ]
1036    : [];
1037  const youW = Math.min(
1038    cells(you.map((s) => s.text).join("")),
1039    Math.max(0, width - 30),
1040  );
1041  const first: Cell[] = [cell(width - youW - (youW ? 2 : 0), next)];
1042  if (youW) first.push(cell(2), cell(youW, you));
1043  push(first);
1044  push([
1045    cell(19, [span("┃ ", { color: MAUVE }), dim("chemin critique  ")]),
1046    cell(Math.max(0, width - 19), chainSpans(epic)),
1047  ]);
1048  const goal: Span[] = [dim("livrable  ")];
1049  if (epic?.goal) {
1050    const g = GLYPH[epic.goal.state];
1051    goal.push(
1052      span(`${g.glyph} `, g.color ? { color: g.color } : { dim: true }),
1053      span(`${epic.goal.label} ${epic.goal.title}`),
1054      dim(` · ${epic.goal.note}`),
1055    );
1056  } else goal.push(dim("aucun"));
1057  push([cell(width, goal)]);
1058  const t = tabsLines("graph", width);
1059  push(t.lines[0]!);
1060  const tabsLine = lines.length;
1061  push(t.lines[1]!);
1062  return {
1063    lines: lines.slice(0, Math.max(rows, 0)),
1064    rowIds: lines.map(() => undefined),
1065    tabsLine: tabsLine < rows ? tabsLine : -1,
1066    tabs: t.tabs,
1067    order: [],
1068  };
1069}
1070
1071/** The view the person chose, or the message when there is no tree. */
1072export function screenOf(
1073  model: TreeModel,
1074  ui: Ui,
1075  width: number,
1076  rows: number,
1077): Screen | string {
1078  if (!model.found)
1079    return "Aucune liste de tâches pour cette session (kodflow-hooks la tient).";
1080  if (!model.epics.length) return "Aucune épopée ouverte.";
1081  return ui.view === "graph"
1082    ? graphScreen(model, ui, width, rows)
1083    : treeScreen(model, ui, width, rows);
1084}
1085
1086// --- drawing --------------------------------------------------------------------------------
1087
1088function drawSpans({ Text }: Els, spans: Span[]) {
1089  return spans.map((s) => (
1090    <Text
1091      {...(s.color && { color: s.color })}
1092      {...(s.dim && { dimColor: true })}
1093      {...(s.bold && { bold: true })}
1094      {...(s.underline && { underline: true })}
1095    >
1096      {s.text}
1097    </Text>
1098  ));
1099}
1100
1101function drawCell(els: Els, c: Cell, key: string): any {
1102  const { Box, Text } = els;
1103  if (c.row)
1104    return (
1105      <Box key={key} width={c.w} flexShrink={0} flexDirection="row">
1106        {c.row.map((inner, i) => drawCell(els, inner, `${key}.${i}`))}
1107      </Box>
1108    );
1109  return (
1110    <Box
1111      key={key}
1112      width={c.w}
1113      flexShrink={0}
1114      {...(c.right && { justifyContent: "flex-end" })}
1115    >
1116      {c.spans?.length ? (
1117        <Text wrap="truncate-end">{drawSpans(els, c.spans)}</Text>
1118      ) : null}
1119    </Box>
1120  );
1121}
1122
1123/** A screen as Boxes: one row a line, one Box of its fixed width a cell. */
1124export function draw(els: Els, screen: Screen | string, width: number) {
1125  const { Box, Text } = els;
1126  if (typeof screen === "string")
1127    return (
1128      <Box key="tasks" flexDirection="column" width={width}>
1129        <Text dimColor wrap="truncate-end">
1130          {screen}
1131        </Text>
1132      </Box>
1133    );
1134  return (
1135    <Box key="tasks" flexDirection="column" width={width}>
1136      {screen.lines.map((line, i) => (
1137        <Box key={`l${i}`} flexDirection="row" height={1} width={width}>
1138          {line.map((c, j) => drawCell(els, c, `l${i}.${j}`))}
1139        </Box>
1140      ))}
1141    </Box>
1142  );
1143}
1144
hooks/mod/colors.ts 141 lines
1// colors.ts — a few words of a reply in colour, never a whole paragraph.
2//
3// The model writes `{{r:words}}`; the transcript draws "words" in red, bold,
4// and the markup is gone. A paragraph with no markup, and every fenced code
5// block, is left to the engine's own markdown. The convention reaches the
6// model as one system prompt section (PROMPT_SECTION), on the session's side
7// of the cache boundary, so it holds after a compaction.
8//
9// Pure: no `$`, nothing read from the host. register.tsx wires it.
10
11/** The four marks and their colours. */
12export const COLORS: Record<string, string> = {
13  r: "red", // blocking
14  o: "#ff8c00", // needs attention
15  v: "green", // waits on the person: their attention or their answer
16  p: "#ff69b4", // a decision the person already made
17};
18
19export type Run = { text: string; color?: string; bold?: boolean };
20export type Block = { markdown: string } | { runs: Run[] };
21
22const MARK = /\{\{([rovp]):([^{}]+?)\}\}/g;
23const HAS_MARK = /\{\{[rovp]:[^{}]+?\}\}/;
24// Inline code, `like this`: a mark inside it is shown as typed
25// (CommonMark: a run of backticks closed by a run of the same length, a
26// shorter run inside it is code too)
27const INLINE_CODE = /(?<!`)(`+)(?!`)[\s\S]*?(?<!`)\1(?!`)/g;
28// A fence opens on three backticks or tildes or more and closes on a line
29// of the same character, at least as long, with nothing after it
30const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
31
32/** Splits on blank lines; a fenced code block stays one block. */
33export function paragraphs(text: string): { text: string; code: boolean }[] {
34  const out: { text: string; code: boolean }[] = [];
35  let buf: string[] = [];
36  let fence = "";
37  const flush = (code: boolean) => {
38    if (buf.length) out.push({ text: buf.join("\n"), code });
39    buf = [];
40  };
41  const closes = (line: string): boolean => {
42    const m = /^ {0,3}(`+|~+)\s*$/.exec(line);
43    return !!m && m[1]![0] === fence[0] && m[1]!.length >= fence.length;
44  };
45  for (const line of text.split("\n")) {
46    if (fence) {
47      buf.push(line);
48      if (closes(line)) {
49        flush(true);
50        fence = "";
51      }
52      continue;
53    }
54    const open = FENCE_OPEN.exec(line);
55    if (open) {
56      flush(false);
57      buf.push(line);
58      fence = open[1]!;
59      continue;
60    }
61    if (line.trim() === "") {
62      flush(false);
63      continue;
64    }
65    buf.push(line);
66  }
67  // An unclosed fence is still code: never interpreted
68  flush(!!fence);
69  return out;
70}
71
72/** One paragraph as runs: coloured marks, **bold**, plain text. */
73export function runs(paragraph: string): Run[] {
74  const out: Run[] = [];
75  const plain = (s: string) => {
76    for (const [i, part] of s.split("**").entries())
77      if (part) out.push(i % 2 ? { text: part, bold: true } : { text: part });
78  };
79  const marks = (s: string) => {
80    let last = 0;
81    for (const m of s.matchAll(MARK)) {
82      plain(s.slice(last, m.index));
83      out.push({ text: m[2]!, color: COLORS[m[1]!], bold: true });
84      last = m.index! + m[0].length;
85    }
86    plain(s.slice(last));
87  };
88  let last = 0;
89  for (const c of paragraph.matchAll(INLINE_CODE)) {
90    marks(paragraph.slice(last, c.index));
91    out.push({ text: c[0] });
92    last = c.index! + c[0].length;
93  }
94  marks(paragraph.slice(last));
95  return out;
96}
97
98/** A reply as blocks: runs where a paragraph is marked, markdown elsewhere. */
99export function blocks(text: string): Block[] {
100  return paragraphs(text).map((p) =>
101    !p.code && HAS_MARK.test(p.text.replace(INLINE_CODE, ""))
102      ? { runs: runs(p.text) }
103      : { markdown: p.text },
104  );
105}
106
107/** True when the reply holds a mark the transcript would colour. */
108export const marked = (parts: Block[]): boolean =>
109  parts.some((p) => "runs" in p);
110
111/** KODFLOW_COLORS=off (or 0, false, no) turns off the colours and the section. */
112export const colorsOff = (value: string | undefined): boolean =>
113  /^(off|0|false|no)$/i.test((value ?? "").trim());
114
115export const SECTION_ID = "kodflow-hud:colors";
116
117/**
118 * The convention as the model reads it. Short on purpose: it rides on every
119 * request of the session.
120 */
121export const PROMPT_SECTION = [
122  "# Coloured words",
123  "",
124  "The terminal colours a few words of your replies when you wrap them in a mark; the mark itself is never shown:",
125  "- `{{r:words}}` red: what blocks (a failure, a refusal, a stop).",
126  "- `{{o:words}}` orange: what needs attention (a risk, a warning, a limit).",
127  "- `{{v:words}}` green: what waits on the user, their answer or their action.",
128  "- `{{p:words}}` pink: a decision the user already made; use it only when you cite that decision with its date.",
129  "",
130  "Colour two to five words at a time, never a sentence or a paragraph, and put at most a few marks in one message; most messages need none. Never put a mark in code, in a command, in a path or in a table meant to be copied, since it would be copied as typed. A paragraph holding a mark loses its other markdown except bold, so keep links, lists and inline code out of it.",
131].join("\n");
132
133/** Whether the prompt being composed is one the transcript will draw. */
134export function drawn(input: {
135  surfaces: readonly string[];
136  traits: readonly string[];
137}): boolean {
138  if (!input.surfaces.length) return false;
139  return !input.traits.some((t) => t === "print" || t === "bare");
140}
141
hooks/mod/models.ts 110 lines
1// models.ts — what each loop runs on, and who worked each task. Pure.
2//
3// The tree's model·effort column shows only what the session said, from
4// three places, the later filling what the earlier left out:
5//   agent-models.tsv  kodflow-hooks' record of every Agent call (its
6//                     PostToolUse: agent id, resolvedModel, the call's
7//                     effort, the type), read for the loops started before
8//                     this module loaded;
9//   agent.spawn       the model core resolved for the subagent it started;
10//   tool.call Agent   the call's `effort` and the result's resolvedModel.
11// The main loop's model is `$.session.model()`; its effort is said nowhere
12// a hooks module reads, so it stays absent. Nothing is guessed: a loop no
13// source names has an empty cell.
14
15import type { HudModel, HudSeen, HudTask } from "../../types";
16
17/**
18 * The models kept are keyed `<session>:<loop>`: "main" is every session's
19 * main loop, and only the current session's entries are kept.
20 */
21export const modelKey = (session: string, loop: string) => `${session}:${loop}`;
22
23/** A record keyed `<session>:…` with only that session's entries left. */
24export function keepSession<T>(
25  record: Readonly<Record<string, T>>,
26  session: string,
27): Record<string, T> {
28  const prefix = `${session}:`;
29  return Object.fromEntries(
30    Object.entries(record).filter(([k]) => k.startsWith(prefix)),
31  );
32}
33
34/** Fields of `b` that are set win over `a`'s. */
35export function merge(a: HudModel | undefined, b: HudModel): HudModel {
36  const out: HudModel = { ...a };
37  if (b.model) out.model = b.model;
38  if (b.effort) out.effort = b.effort;
39  return out;
40}
41
42const str = (v: unknown): string =>
43  typeof v === "string" ? v.trim() : typeof v === "number" ? String(v) : "";
44
45/** agent-models.tsv: `agentId \t resolvedModel \t effort \t type`, latest last. */
46export function parseModels(
47  text: string | undefined,
48): Record<string, HudModel> {
49  const out: Record<string, HudModel> = {};
50  for (const line of (text ?? "").split("\n")) {
51    const [id, model, effort] = line.split("\t");
52    if (!id) continue;
53    out[id] = merge(out[id], {
54      ...(model && { model }),
55      ...(effort && { effort }),
56    });
57  }
58  return out;
59}
60
61/** What an Agent call says of the loop it started: its id, model, effort. */
62export function fromAgentCall(
63  input: { effort?: unknown },
64  result: unknown,
65): { id: string; model: HudModel } | undefined {
66  const r = (result ?? {}) as { agentId?: unknown; resolvedModel?: unknown };
67  const id = str(r.agentId);
68  if (!id) return undefined;
69  const model: HudModel = {};
70  const m = str(r.resolvedModel);
71  const effort = str(input.effort);
72  if (m) model.model = m;
73  if (effort) model.effort = effort;
74  return { id, model };
75}
76
77/**
78 * The worker of every task in progress, added to what was seen before:
79 * tasks.json drops the link when a task leaves in_progress, the tree still
80 * names who did it. Keyed by session too, as task ids start over in each
81 * session's file; only the current session's are kept, so the record is
82 * bounded by one list. Undefined when nothing changed.
83 */
84export const seenKey = (session: string, t: Pick<HudTask, "agent" | "id">) =>
85  `${session}:${t.agent}:${t.id}`;
86
87export function seenFrom(
88  tasks: readonly HudTask[],
89  before: Readonly<Record<string, HudSeen>>,
90  session: string,
91): Record<string, HudSeen> | undefined {
92  const prefix = `${session}:`;
93  const stale = Object.keys(before).some((k) => !k.startsWith(prefix));
94  let out: Record<string, HudSeen> | undefined = stale
95    ? keepSession(before, session)
96    : undefined;
97  for (const t of tasks) {
98    if (t.status !== "in_progress" || !(t.worker || t.workerId)) continue;
99    const key = seenKey(session, t);
100    const was = (out ?? before)[key];
101    if (was?.worker === t.worker && was?.workerId === t.workerId) continue;
102    out ??= { ...before };
103    out[key] = {
104      ...(t.worker && { worker: t.worker }),
105      ...(t.workerId && { workerId: t.workerId }),
106    };
107  }
108  return out;
109}
110