@capagents/forgeqe 2.0.3 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capagents/forgeqe",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
4
4
  "description": "ForgeQE: a browser agent CLI that runs goals and test cases in local Chrome or Chromium, or BrowserStack and TestMu AI/LambdaTest when configured, and turns passing runs into tests in your repo",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,39 +1,75 @@
1
1
  import { AGENT_HUES, theme } from "./theme.ts"
2
2
 
3
3
  /**
4
- * Each agent's face in the shell: a short name and an icon, so it is clear which agent or subagent is working. The icon
5
- * is one character, a picture of the agent's job, in a colour no other agent has; the name is in that colour too.
6
- * Plain data, tested without a terminal.
4
+ * Each agent's face in the shell: a short name and an icon, so it is clear which agent or subagent is working. The
5
+ * icon is a picture of the agent's job after one set of hexagon tiles (soft tones, a deeper one where shapes overlap),
6
+ * in a colour no other agent has; the name is in that colour too. Plain data, tested without a terminal.
7
7
  */
8
8
 
9
- export type Hue = keyof typeof AGENT_HUES
10
-
11
9
  /**
12
- * `icon`: one character, one column wide in every terminal. None of them is a mark the shell uses for a state
13
- * (✓ ✗ ○ ◐ ■ ▸ ✎ ↻), so an agent is never read as a result.
10
+ * Eight pixels wide and four high: `#` the agent's main tone, `+` its deeper tone (where shapes overlap, or the part
11
+ * that stands out), `.` empty. A quadrant block holds two by two pixels, so a picture is four characters on two rows.
14
12
  */
15
- export type AgentFace = { name: string; icon: string; hue: Hue }
13
+ export const SHAPES = {
14
+ /** A plain hexagon: an agent not listed. */
15
+ hexagon: ["..####..", ".######.", ".######.", "..####.."],
16
+ /** Hexagons stacked into one: ForgeQE's agent, which starts the others. */
17
+ hub: ["...##...", ".##++##.", "##++++##", ".##..##."],
18
+ /** A browser window with its title bar. */
19
+ window: ["++++++++", "#......#", "#......#", "########"],
20
+ /** A magnifier. */
21
+ magnifier: [".###....", "#+++#...", ".###.#..", ".....##."],
22
+ /** A bar chart. */
23
+ chart: ["......##", "...##.##", "##.##.##", "++++++++"],
24
+ /** A funnel: a request narrowed to a specification. */
25
+ funnel: ["########", ".######.", "..++++..", "...++..."],
26
+ /** A list with its bullets. */
27
+ list: ["+.######", "........", "+.####..", "........"],
28
+ /** A flag on its pole. */
29
+ flag: ["##+++++.", "##++++..", "##......", "##......"],
30
+ /** A pencil, its tip at the bottom left. */
31
+ pencil: [".....+++", "...###+.", ".###....", "+#......"],
32
+ /** An open book on its spine. */
33
+ book: ["##....##", "###..###", "###++###", "...++..."],
34
+ /** A cross. */
35
+ cross: ["...##...", ".##++##.", ".##++##.", "...##..."],
36
+ /** A plug with its prongs and cable. */
37
+ plug: ["..#..#..", ".++++++.", ".++++++.", "...##..."],
38
+ /** A check mark. */
39
+ check: ["......##", "##...##.", ".##+##..", "..+++..."],
40
+ /** A comment bubble. */
41
+ bubble: ["########", "#.+.+.+#", "########", ".##....."],
42
+ /** A gavel on its block. */
43
+ gavel: [".++++...", ".++++#..", ".....##.", "###...##"],
44
+ /** A bug. */
45
+ bug: ["+..##..+", ".######.", "+##++##+", ".##++##."],
46
+ } as const
47
+
48
+ export type Shape = keyof typeof SHAPES
49
+ export type Hue = keyof typeof AGENT_HUES
50
+
51
+ export type AgentFace = { name: string; shape: Shape; hue: Hue }
16
52
 
17
53
  /**
18
54
  * Keyed by the agent's name in src/agents.ts (its AgentSpec name, or "engine"). Each has its own hue; the hue's
19
55
  * neighbourhood says what the agent does: purples plan and lead, blues run and look, greens write code, warm hues judge.
20
56
  */
21
57
  export const FACES: Record<string, AgentFace> = {
22
- forgeqe: { name: "ForgeQE's agent", icon: "⬢", hue: "purple" },
23
- "forgeqe-refine": { name: "Refine", icon: "▽", hue: "indigo" },
24
- "forgeqe-api-plan": { name: "API planner", icon: "≡", hue: "orchid" },
25
- "forgeqe-explore-plan": { name: "Explorer", icon: "⚑", hue: "magenta" },
26
- engine: { name: "Browser agent", icon: "◫", hue: "blue" },
27
- "forgeqe-find": { name: "Test finder", icon: "◎", hue: "cyan" },
28
- "forgeqe-bi": { name: "Insights", icon: "▥", hue: "yellow" },
29
- "forgeqe-test": { name: "Scribe", icon: "✐", hue: "green" },
30
- "forgeqe-read": { name: "Scribe, reading", icon: "▤", hue: "leaf" },
31
- "forgeqe-heal": { name: "Healer", icon: "✚", hue: "lime" },
32
- "forgeqe-api": { name: "API test writer", icon: "⇌", hue: "teal" },
33
- "forgeqe-review": { name: "Reviewer", icon: "◈", hue: "pink" },
34
- "forgeqe-pr-review": { name: "PR reviewer", icon: "⎇", hue: "rose" },
35
- "forgeqe-judge": { name: "Judge", icon: "⚖", hue: "coral" },
36
- "forgeqe-defect-rca": { name: "Root cause", icon: "✱", hue: "orange" },
58
+ forgeqe: { name: "ForgeQE's agent", shape: "hub", hue: "purple" },
59
+ "forgeqe-refine": { name: "Refine", shape: "funnel", hue: "indigo" },
60
+ "forgeqe-api-plan": { name: "API planner", shape: "list", hue: "orchid" },
61
+ "forgeqe-explore-plan": { name: "Explorer", shape: "flag", hue: "magenta" },
62
+ engine: { name: "Browser agent", shape: "window", hue: "blue" },
63
+ "forgeqe-find": { name: "Test finder", shape: "magnifier", hue: "cyan" },
64
+ "forgeqe-bi": { name: "Insights", shape: "chart", hue: "yellow" },
65
+ "forgeqe-test": { name: "Scribe", shape: "pencil", hue: "green" },
66
+ "forgeqe-read": { name: "Scribe, reading", shape: "book", hue: "leaf" },
67
+ "forgeqe-heal": { name: "Healer", shape: "cross", hue: "lime" },
68
+ "forgeqe-api": { name: "API test writer", shape: "plug", hue: "teal" },
69
+ "forgeqe-review": { name: "Reviewer", shape: "check", hue: "pink" },
70
+ "forgeqe-pr-review": { name: "PR reviewer", shape: "bubble", hue: "rose" },
71
+ "forgeqe-judge": { name: "Judge", shape: "gavel", hue: "coral" },
72
+ "forgeqe-defect-rca": { name: "Root cause", shape: "bug", hue: "orange" },
37
73
  }
38
74
 
39
75
  export const BRAIN = "forgeqe"
@@ -41,9 +77,51 @@ export const BROWSER = "engine"
41
77
  export const SCRIBE = "forgeqe-test"
42
78
 
43
79
  /** An agent's face; one not listed is named by its id, without the "forgeqe-" prefix, with a plain grey hexagon. */
44
- export function faceOf(agent: string): AgentFace & { main: string } {
80
+ export function faceOf(agent: string): AgentFace & { main: string; deep: string } {
45
81
  const face = FACES[agent]
46
- if (face) return { ...face, main: AGENT_HUES[face.hue] }
82
+ if (face) return { ...face, ...AGENT_HUES[face.hue] }
47
83
  const name = agent.replace(/^forgeqe-/, "").replace(/-/g, " ")
48
- return { name: name.charAt(0).toUpperCase() + name.slice(1), icon: "⬡", hue: "blue", main: theme.faint }
84
+ return { name: name.charAt(0).toUpperCase() + name.slice(1), shape: "hexagon", hue: "blue", main: theme.faint, deep: theme.border }
85
+ }
86
+
87
+ /** Columns an icon takes: two pixels to a quadrant block. */
88
+ export const ICON_WIDTH = SHAPES.hexagon[0].length / 2
89
+
90
+ /** The quadrant block for each set of its four pixels: top left 1, top right 2, bottom left 4, bottom right 8. */
91
+ const QUADRANT = " ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█"
92
+
93
+ /** One character of an icon and its colour. */
94
+ export type IconCell = { ch: string; fg: string }
95
+
96
+ /** "#7fe0d6" toward white by `amount` (0 to 1): the highlight that sweeps across a working agent's icon. */
97
+ export function lighten(hex: string, amount: number): string {
98
+ const n = parseInt(hex.slice(1), 16)
99
+ const channel = (shift: number) => {
100
+ const v = (n >> shift) & 0xff
101
+ return Math.round(v + (255 - v) * amount)
102
+ }
103
+ return `#${[16, 8, 0].map((s) => channel(s).toString(16).padStart(2, "0")).join("")}`
104
+ }
105
+
106
+ /** Frames in one sweep of the highlight: across the icon, then a pause as long. */
107
+ export const SWEEP_FRAMES = ICON_WIDTH * 2
108
+
109
+ /**
110
+ * The agent's icon as two rows of quadrant blocks, each in the tone most of its pixels have (a tie keeps the main
111
+ * tone). `frame`: while the agent works, the frame of the highlight that sweeps across it, a column at a time, then
112
+ * rests; undefined, the icon is still.
113
+ */
114
+ export function iconCells(agent: string, frame?: number): IconCell[][] {
115
+ const face = faceOf(agent)
116
+ const px = SHAPES[face.shape]
117
+ const lit = frame === undefined ? -1 : frame % SWEEP_FRAMES
118
+ return [0, 1].map((r) =>
119
+ Array.from({ length: ICON_WIDTH }, (_, c) => {
120
+ const pixels = [px[r * 2]![c * 2], px[r * 2]![c * 2 + 1], px[r * 2 + 1]![c * 2], px[r * 2 + 1]![c * 2 + 1]]
121
+ const bits = pixels.reduce((n, p, i) => (p === "#" || p === "+" ? n | (1 << i) : n), 0)
122
+ const deep = pixels.filter((p) => p === "+").length > pixels.filter((p) => p === "#").length
123
+ const tone = deep ? face.deep : face.main
124
+ return { ch: QUADRANT[bits]!, fg: c === lit ? lighten(tone, 0.6) : tone }
125
+ }),
126
+ )
49
127
  }
@@ -1,8 +1,8 @@
1
1
  import type { ScrollBoxRenderable } from "@opentui/core"
2
- import type { ReactNode, RefObject } from "react"
2
+ import { useEffect, useState, type ReactNode, type RefObject } from "react"
3
3
 
4
4
  import type { Usage } from "../types.ts"
5
- import { faceOf } from "./agentIcons.ts"
5
+ import { faceOf, iconCells, ICON_WIDTH } from "./agentIcons.ts"
6
6
  import { fitButtons, Spinner, type Button } from "./components.tsx"
7
7
  import { crewMs, filesByUse, type CrewMember, type FileUse, type Inspector, type Progress } from "./session.ts"
8
8
  import { clip, plainText, tailCut } from "./text.ts"
@@ -67,10 +67,33 @@ export function Panel(props: { title: string; color: string; width?: number; hei
67
67
  )
68
68
  }
69
69
 
70
- /** An agent's icon and name in its colour, on one line: "◫ Browser agent". */
71
- export function AgentName({ agent, max = 40 }: { agent: string; max?: number }) {
72
- const face = faceOf(agent)
73
- return <text fg={face.main}>{`${face.icon} ${clip(face.name, Math.max(4, max - 2))}`}</text>
70
+ /** Milliseconds a frame of a working agent's icon shows: the highlight crosses it and rests in about a second. */
71
+ const SWEEP_MS = 140
72
+
73
+ /**
74
+ * An agent's icon (src/tui/agentIcons.ts): its picture in quadrant blocks, four columns and two rows, in its colours.
75
+ * `live`: the agent is working, and a highlight sweeps across the icon in place of a spinner.
76
+ */
77
+ export function AgentIcon({ agent, live = false }: { agent: string; live?: boolean }) {
78
+ const [frame, setFrame] = useState(0)
79
+ useEffect(() => {
80
+ if (!live) return
81
+ const timer = setInterval(() => setFrame((f) => f + 1), SWEEP_MS)
82
+ return () => clearInterval(timer)
83
+ }, [live])
84
+ return (
85
+ <box style={{ flexDirection: "column", width: ICON_WIDTH, height: 2, flexShrink: 0 }}>
86
+ {iconCells(agent, live ? frame : undefined).map((row, r) => (
87
+ <text key={r}>
88
+ {row.map((cell, c) => (
89
+ <span key={c} fg={cell.fg}>
90
+ {cell.ch}
91
+ </span>
92
+ ))}
93
+ </text>
94
+ ))}
95
+ </box>
96
+ )
74
97
  }
75
98
 
76
99
  /** How far the agent's own plan has got, while it has one of two steps or more. */
@@ -94,8 +117,10 @@ export const STATUS_ROWS = 2
94
117
  export function StatusBar(props: {
95
118
  running: boolean
96
119
  who: string
97
- /** The agent working (its name in src/agents.ts): while the request runs, `who` is written with its icon and colour. */
120
+ /** The agent working (its name in src/agents.ts): its icon leads both rows while the request runs, and moves. */
98
121
  agent?: string
122
+ /** The colour `who` is written in: the working agent's own; its icon stands in for the spinner. */
123
+ whoColor?: string
99
124
  activity: string
100
125
  elapsed: string
101
126
  status: string
@@ -106,7 +131,17 @@ export function StatusBar(props: {
106
131
  /** Buttons for the view (Focus panels, Close), before Stop; the first to give way when the line is short. */
107
132
  actions?: Button[]
108
133
  }) {
109
- const face = props.running && props.agent ? faceOf(props.agent) : null
134
+ const icon = props.running && props.agent ? props.agent : null
135
+ if (icon) {
136
+ return (
137
+ <box style={{ flexDirection: "row", height: STATUS_ROWS, flexShrink: 0, paddingLeft: 1 }}>
138
+ <AgentIcon agent={icon} live />
139
+ <box style={{ width: props.width - ICON_WIDTH - 1, flexShrink: 0 }}>
140
+ <StatusBar {...props} agent={undefined} whoColor={faceOf(icon).main} width={props.width - ICON_WIDTH - 1} />
141
+ </box>
142
+ </box>
143
+ )
144
+ }
110
145
  const done = props.status === "done"
111
146
  const failed = props.status === "fail"
112
147
  const color = props.running ? theme.warn : done ? theme.ok : failed ? theme.err : theme.muted
@@ -115,15 +150,15 @@ export function StatusBar(props: {
115
150
  const cells = 12
116
151
  const barText = bar ? ` ${bar.done}/${bar.total} ` : ""
117
152
  const stop = "■ Stop · esc"
118
- const fixed = state.length + 4 + (face ? 2 : 0) + (props.running ? stop.length + 4 + props.elapsed.length + 2 + (bar ? cells + barText.length : 0) : 0)
153
+ const fixed = state.length + 4 + (props.running ? stop.length + 4 + props.elapsed.length + 2 + (bar ? cells + barText.length : 0) : 0)
119
154
  const actions = fitButtons(props.actions || [], Math.max(0, props.width - fixed - 2))
120
155
  const [full, empty] = bar ? progressBar(bar.done, bar.total, cells) : ["", ""]
121
156
  return (
122
157
  <box style={{ flexDirection: "column", height: STATUS_ROWS, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
123
158
  <box style={{ flexDirection: "row", justifyContent: "space-between", height: 1 }}>
124
159
  <box style={{ flexDirection: "row" }}>
125
- {props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={color}>{"●"}</text>}
126
- <text fg={face ? face.main : props.running ? theme.text : color}>{`${face ? ` ${face.icon}` : ""} ${clip(state, Math.max(8, props.width - fixed + state.length))}`}</text>
160
+ {props.whoColor ? null : props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={color}>{"●"}</text>}
161
+ <text fg={props.running ? props.whoColor || theme.text : color}>{`${props.whoColor ? "" : " "}${clip(state, Math.max(8, props.width - fixed + state.length))}`}</text>
127
162
  </box>
128
163
  <box style={{ flexDirection: "row" }}>
129
164
  {actions.map((b) => (
@@ -170,19 +205,18 @@ const CREW_MARK: Record<CrewMember["state"], [string, string]> = {
170
205
 
171
206
  /** Rows the crew strip takes, the blank row under it included: none until a second agent has joined the request. */
172
207
  export function crewRows(s: Inspector): number {
173
- return s.crew.length > 1 ? 2 : 0
208
+ return s.crew.length > 1 ? 3 : 0
174
209
  }
175
210
 
176
- /** What a member's state says: "waiting", or how long it has worked. */
211
+ /** What a member's second row says: "waiting", or how long it has worked. */
177
212
  function crewState(m: CrewMember, now: number): string {
178
213
  return m.state === "waiting" ? "waiting" : formatDuration(crewMs(m, now))
179
214
  }
180
215
 
181
- /** Most columns an agent's name takes in the strip. */
182
- const CREW_NAME = 18
216
+ /** Columns a tile's text takes, and the tile with its icon. */
217
+ const CREW_TEXT = 18
218
+ const crewTextWidth = (m: CrewMember, now: number) => Math.min(CREW_TEXT, Math.max(faceOf(m.agent).name.length, crewState(m, now).length + 2))
183
219
  const CREW_GAP = " › "
184
- /** "◫ Browser agent ✓ 39s": an agent in the strip, its icon, name, state mark, and state. */
185
- const crewWidth = (m: CrewMember, now: number) => 2 + Math.min(CREW_NAME, faceOf(m.agent).name.length) + 3 + crewState(m, now).length
186
220
 
187
221
  /**
188
222
  * The chat view's line under the conversation while a request runs (the panels closed): the working agent's icon, its
@@ -192,11 +226,11 @@ const crewWidth = (m: CrewMember, now: number) => 2 + Math.min(CREW_NAME, faceOf
192
226
  export function AgentWorking(props: { s: Inspector; activity: string; stats: string; width: number }) {
193
227
  const { s } = props
194
228
  const face = faceOf(s.agent)
195
- const room = Math.max(10, props.width - 2)
196
- const name = clip(face.name, Math.max(8, room - 6))
229
+ const room = Math.max(10, props.width - ICON_WIDTH - 1)
230
+ const name = clip(face.name, Math.max(8, room - 4))
197
231
  const stats = `(${props.stats})`
198
232
  const chain = s.crew.length > 1 ? s.crew : []
199
- const size = (m: CrewMember) => 4 + faceOf(m.agent).name.length
233
+ const size = (m: CrewMember) => 2 + faceOf(m.agent).name.length
200
234
  let first = chain.length
201
235
  let used = 0
202
236
  while (first > 0) {
@@ -206,40 +240,40 @@ export function AgentWorking(props: { s: Inspector; activity: string; stats: str
206
240
  first--
207
241
  }
208
242
  return (
209
- <box style={{ flexDirection: "column", marginTop: 1, flexShrink: 0 }}>
210
- <box style={{ flexDirection: "row", height: 1 }}>
211
- <Spinner kind="dots" color={face.main} />
212
- <text>
213
- <span fg={face.main}>{` ${face.icon} ${name}`}</span>
214
- <span fg={theme.memory}>{` ${clip(props.activity || "Working…", Math.max(8, room - name.length - 6))}`}</span>
243
+ <box style={{ flexDirection: "row", marginTop: 1, flexShrink: 0 }}>
244
+ <AgentIcon agent={s.agent} live />
245
+ <box style={{ flexDirection: "column", paddingLeft: 1, flexGrow: 1, flexShrink: 1 }}>
246
+ <box style={{ flexDirection: "row", height: 1 }}>
247
+ <text>
248
+ <span fg={face.main}>{name}</span>
249
+ <span fg={theme.memory}>{` ${clip(props.activity || "Working…", Math.max(8, room - name.length - 4))}`}</span>
250
+ </text>
251
+ </box>
252
+ <text style={{ height: 1 }}>
253
+ {first > 0 ? <span fg={theme.faint}>{`+${first} `}</span> : null}
254
+ {chain.slice(first).flatMap((m, i) => {
255
+ const [mark, color] = CREW_MARK[m.state]
256
+ return [
257
+ ...(i ? [<span key={`${m.agent}-gap`} fg={theme.border}>{CREW_GAP}</span>] : []),
258
+ <span key={`${m.agent}-mark`} fg={color}>{`${mark} `}</span>,
259
+ <span key={m.agent} fg={faceOf(m.agent).main}>{faceOf(m.agent).name}</span>,
260
+ ]
261
+ })}
262
+ <span fg={theme.faint}>{`${chain.length ? " " : ""}${clip(stats, Math.max(8, room - used))}`}</span>
215
263
  </text>
216
264
  </box>
217
- <text style={{ height: 1, paddingLeft: 2 }}>
218
- {first > 0 ? <span fg={theme.faint}>{`+${first} `}</span> : null}
219
- {chain.slice(first).flatMap((m, i) => {
220
- const [mark, color] = CREW_MARK[m.state]
221
- const f = faceOf(m.agent)
222
- return [
223
- ...(i ? [<span key={`${m.agent}-gap`} fg={theme.border}>{CREW_GAP}</span>] : []),
224
- <span key={`${m.agent}-mark`} fg={color}>{`${mark} `}</span>,
225
- <span key={m.agent} fg={f.main}>{`${f.icon} ${f.name}`}</span>,
226
- ]
227
- })}
228
- <span fg={theme.faint}>{`${chain.length ? " " : ""}${clip(stats, Math.max(8, room - used))}`}</span>
229
- </text>
230
265
  </box>
231
266
  )
232
267
  }
233
268
 
234
269
  /**
235
- * Every agent that took part in the request, in the order they started, on one line: each one's icon and name, and
236
- * whether it is working (spinning, with its time), waiting for a subagent, or done. The latest that fit; "+N" for the
237
- * earlier.
270
+ * Every agent that took part in the request, in the order they started, as tiles: the icon, its name, and whether it
271
+ * is working (its icon moving, with its time), waiting for a subagent, or done. The latest that fit; "+N" for the earlier.
238
272
  */
239
273
  export function CrewStrip(props: { s: Inspector; width: number; running: boolean; now?: number }) {
240
274
  const now = props.now ?? Date.now()
241
275
  const crew = props.s.crew
242
- const tileWidth = (m: CrewMember) => crewWidth(m, now)
276
+ const tileWidth = (m: CrewMember) => ICON_WIDTH + 1 + crewTextWidth(m, now)
243
277
  let first = crew.length
244
278
  let used = 0
245
279
  while (first > 0) {
@@ -250,19 +284,26 @@ export function CrewStrip(props: { s: Inspector; width: number; running: boolean
250
284
  first--
251
285
  }
252
286
  return (
253
- <box style={{ flexDirection: "row", height: 1, flexShrink: 0, marginBottom: 1, paddingLeft: 1 }}>
287
+ <box style={{ flexDirection: "row", height: 2, flexShrink: 0, marginBottom: 1, paddingLeft: 1 }}>
254
288
  {first > 0 ? <text fg={theme.faint}>{`+${first} `}</text> : null}
255
289
  {crew.slice(first).map((m, i) => {
290
+ const face = faceOf(m.agent)
256
291
  const [mark, color] = CREW_MARK[m.state]
257
292
  const live = m.state === "working" && props.running
293
+ const text = crewTextWidth(m, now)
258
294
  return (
259
295
  <box key={m.agent} style={{ flexDirection: "row", flexShrink: 0 }}>
260
296
  {i ? <text fg={theme.border}>{CREW_GAP}</text> : null}
261
- {/* Each agent's icon and name in its own colour, as in the status bar. */}
262
- <AgentName agent={m.agent} max={CREW_NAME + 2} />
263
- <text>{" "}</text>
264
- <box style={{ width: 2, flexShrink: 0 }}>{live ? <Spinner kind="dots" color={color} /> : <text fg={color}>{mark}</text>}</box>
265
- <text fg={m.state === "working" ? theme.warn : theme.faint}>{crewState(m, now)}</text>
297
+ <AgentIcon agent={m.agent} live={live} />
298
+ <box style={{ flexDirection: "column", width: text + 1, paddingLeft: 1, flexShrink: 0 }}>
299
+ {/* Each agent's name in its own colour, so its tile, the status bar, and the icon read as one agent. */}
300
+ <text fg={face.main}>{clip(face.name, text)}</text>
301
+ <box style={{ flexDirection: "row", height: 1 }}>
302
+ {/* The working agent's icon moves, so its time needs no spinner beside it. */}
303
+ {live ? null : <text fg={color} style={{ width: 2, flexShrink: 0 }}>{mark}</text>}
304
+ <text fg={m.state === "working" ? theme.warn : theme.faint}>{crewState(m, now)}</text>
305
+ </box>
306
+ </box>
266
307
  </box>
267
308
  )
268
309
  })}
package/src/tui/theme.ts CHANGED
@@ -20,29 +20,29 @@ export const theme = {
20
20
  }
21
21
 
22
22
  /**
23
- * The agents' colours (src/tui/agentIcons.ts), one per agent, for its icon and its name. Neighbouring hues go to agents
24
- * that do the same kind of work.
23
+ * The agents' colours (src/tui/agentIcons.ts), one per agent: its main tone, for its name and icon, and the deeper one
24
+ * where its icon's shapes overlap. Neighbouring hues go to agents that do the same kind of work.
25
25
  */
26
26
  export const AGENT_HUES = {
27
27
  // Plan and lead.
28
- purple: "#a78bfa",
29
- indigo: "#8fa2ff",
30
- orchid: "#e0a3f5",
31
- magenta: "#f59fe0",
28
+ purple: { main: "#a78bfa", deep: "#5b2bc4" },
29
+ indigo: { main: "#8fa2ff", deep: "#3d4fd6" },
30
+ orchid: { main: "#e0a3f5", deep: "#a443d6" },
31
+ magenta: { main: "#f59fe0", deep: "#c2389e" },
32
32
  // Run and look.
33
- blue: "#a9d0f8",
34
- cyan: "#8ee3f5",
35
- yellow: "#f0e48c",
33
+ blue: { main: "#a9d0f8", deep: "#5ea6f0" },
34
+ cyan: { main: "#8ee3f5", deep: "#1fa3c4" },
35
+ yellow: { main: "#f0e48c", deep: "#b8a32a" },
36
36
  // Write code.
37
- green: "#6fd3b2",
38
- leaf: "#9be89b",
39
- lime: "#cde88a",
40
- teal: "#7fe0d6",
37
+ green: { main: "#6fd3b2", deep: "#3a9a7e" },
38
+ leaf: { main: "#9be89b", deep: "#3fa34d" },
39
+ lime: { main: "#cde88a", deep: "#86a92c" },
40
+ teal: { main: "#7fe0d6", deep: "#1f9c90" },
41
41
  // Judge.
42
- pink: "#f29bc9",
43
- rose: "#ff9eaa",
44
- coral: "#ffb199",
45
- orange: "#ffc58a",
42
+ pink: { main: "#f29bc9", deep: "#e0457b" },
43
+ rose: { main: "#ff9eaa", deep: "#d93a52" },
44
+ coral: { main: "#ffb199", deep: "#e0603a" },
45
+ orange: { main: "#ffc58a", deep: "#d6701f" },
46
46
  }
47
47
 
48
48
  /** 950 → "950", 47_100 → "47.1K", 2_300_000 → "2.3M". */