@capagents/forgeqe 2.0.2 → 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.2",
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",
@@ -2,14 +2,13 @@ import { AGENT_HUES, theme } from "./theme.ts"
2
2
 
3
3
  /**
4
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
- * icons follow one set of hexagon tiles (soft tones, a deeper one where shapes overlap); each agent has a picture of its
6
- * job and a colour of its own, for its icon and its name.
7
- * Plain data, tested without a terminal.
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.
8
7
  */
9
8
 
10
9
  /**
11
- * Eight pixels wide and four high, drawn as two rows of half blocks: `#` the agent's main tone, `+` its deeper tone
12
- * (where shapes overlap, or the part that stands out), `.` empty. Each agent's is a picture of its job.
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.
13
12
  */
14
13
  export const SHAPES = {
15
14
  /** A plain hexagon: an agent not listed. */
@@ -85,25 +84,44 @@ export function faceOf(agent: string): AgentFace & { main: string; deep: string
85
84
  return { name: name.charAt(0).toUpperCase() + name.slice(1), shape: "hexagon", hue: "blue", main: theme.faint, deep: theme.border }
86
85
  }
87
86
 
88
- /** One terminal cell of an icon: a half or full block in up to two colours, or a blank. */
89
- export type IconCell = { ch: string; fg?: string; bg?: string }
87
+ /** Columns an icon takes: two pixels to a quadrant block. */
88
+ export const ICON_WIDTH = SHAPES.hexagon[0].length / 2
90
89
 
91
- /** The agent's icon as two rows of cells: a pixel's tone on top is the foreground of ▀, the one below its background. */
92
- export function iconCells(agent: string): IconCell[][] {
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[][] {
93
115
  const face = faceOf(agent)
94
116
  const px = SHAPES[face.shape]
95
- const tone = (p: string | undefined) => (p === "#" ? face.main : p === "+" ? face.deep : undefined)
117
+ const lit = frame === undefined ? -1 : frame % SWEEP_FRAMES
96
118
  return [0, 1].map((r) =>
97
- [...px[0]].map((_, c) => {
98
- const top = tone(px[r * 2]![c])
99
- const bottom = tone(px[r * 2 + 1]![c])
100
- if (!top && !bottom) return { ch: " " }
101
- if (!bottom) return { ch: "▀", fg: top }
102
- if (!top) return { ch: "▄", fg: bottom }
103
- return top === bottom ? { ch: "█", fg: top } : { ch: "▀", fg: top, bg: bottom }
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 }
104
125
  }),
105
126
  )
106
127
  }
107
-
108
- /** Columns an icon takes. */
109
- export const ICON_WIDTH = SHAPES.hexagon[0].length
@@ -1,5 +1,5 @@
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
5
  import { faceOf, iconCells, ICON_WIDTH } from "./agentIcons.ts"
@@ -67,14 +67,26 @@ export function Panel(props: { title: string; color: string; width?: number; hei
67
67
  )
68
68
  }
69
69
 
70
- /** An agent's icon (src/tui/agentIcons.ts): two rows of half blocks in its hue. */
71
- export function AgentIcon({ agent }: { agent: string }) {
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])
72
84
  return (
73
85
  <box style={{ flexDirection: "column", width: ICON_WIDTH, height: 2, flexShrink: 0 }}>
74
- {iconCells(agent).map((row, r) => (
86
+ {iconCells(agent, live ? frame : undefined).map((row, r) => (
75
87
  <text key={r}>
76
88
  {row.map((cell, c) => (
77
- <span key={c} {...(cell.fg ? { fg: cell.fg } : {})} {...(cell.bg ? { bg: cell.bg } : {})}>
89
+ <span key={c} fg={cell.fg}>
78
90
  {cell.ch}
79
91
  </span>
80
92
  ))}
@@ -105,9 +117,9 @@ export const STATUS_ROWS = 2
105
117
  export function StatusBar(props: {
106
118
  running: boolean
107
119
  who: string
108
- /** The agent working (its name in src/agents.ts): its icon leads both rows while the request runs. */
120
+ /** The agent working (its name in src/agents.ts): its icon leads both rows while the request runs, and moves. */
109
121
  agent?: string
110
- /** The colour `who` is written in: the working agent's own. */
122
+ /** The colour `who` is written in: the working agent's own; its icon stands in for the spinner. */
111
123
  whoColor?: string
112
124
  activity: string
113
125
  elapsed: string
@@ -123,7 +135,7 @@ export function StatusBar(props: {
123
135
  if (icon) {
124
136
  return (
125
137
  <box style={{ flexDirection: "row", height: STATUS_ROWS, flexShrink: 0, paddingLeft: 1 }}>
126
- <AgentIcon agent={icon} />
138
+ <AgentIcon agent={icon} live />
127
139
  <box style={{ width: props.width - ICON_WIDTH - 1, flexShrink: 0 }}>
128
140
  <StatusBar {...props} agent={undefined} whoColor={faceOf(icon).main} width={props.width - ICON_WIDTH - 1} />
129
141
  </box>
@@ -145,8 +157,8 @@ export function StatusBar(props: {
145
157
  <box style={{ flexDirection: "column", height: STATUS_ROWS, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
146
158
  <box style={{ flexDirection: "row", justifyContent: "space-between", height: 1 }}>
147
159
  <box style={{ flexDirection: "row" }}>
148
- {props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={color}>{"●"}</text>}
149
- <text fg={props.running ? props.whoColor || theme.text : color}>{` ${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>
150
162
  </box>
151
163
  <box style={{ flexDirection: "row" }}>
152
164
  {actions.map((b) => (
@@ -229,12 +241,11 @@ export function AgentWorking(props: { s: Inspector; activity: string; stats: str
229
241
  }
230
242
  return (
231
243
  <box style={{ flexDirection: "row", marginTop: 1, flexShrink: 0 }}>
232
- <AgentIcon agent={s.agent} />
244
+ <AgentIcon agent={s.agent} live />
233
245
  <box style={{ flexDirection: "column", paddingLeft: 1, flexGrow: 1, flexShrink: 1 }}>
234
246
  <box style={{ flexDirection: "row", height: 1 }}>
235
- <Spinner kind="dots" color={face.main} />
236
247
  <text>
237
- <span fg={face.main}>{` ${name}`}</span>
248
+ <span fg={face.main}>{name}</span>
238
249
  <span fg={theme.memory}>{` ${clip(props.activity || "Working…", Math.max(8, room - name.length - 4))}`}</span>
239
250
  </text>
240
251
  </box>
@@ -257,7 +268,7 @@ export function AgentWorking(props: { s: Inspector; activity: string; stats: str
257
268
 
258
269
  /**
259
270
  * Every agent that took part in the request, in the order they started, as tiles: the icon, its name, and whether it
260
- * is working (spinning, with its time), waiting for a subagent, or done. The latest that fit; "+N" for the earlier.
271
+ * is working (its icon moving, with its time), waiting for a subagent, or done. The latest that fit; "+N" for the earlier.
261
272
  */
262
273
  export function CrewStrip(props: { s: Inspector; width: number; running: boolean; now?: number }) {
263
274
  const now = props.now ?? Date.now()
@@ -283,12 +294,13 @@ export function CrewStrip(props: { s: Inspector; width: number; running: boolean
283
294
  return (
284
295
  <box key={m.agent} style={{ flexDirection: "row", flexShrink: 0 }}>
285
296
  {i ? <text fg={theme.border}>{CREW_GAP}</text> : null}
286
- <AgentIcon agent={m.agent} />
297
+ <AgentIcon agent={m.agent} live={live} />
287
298
  <box style={{ flexDirection: "column", width: text + 1, paddingLeft: 1, flexShrink: 0 }}>
288
299
  {/* Each agent's name in its own colour, so its tile, the status bar, and the icon read as one agent. */}
289
300
  <text fg={face.main}>{clip(face.name, text)}</text>
290
301
  <box style={{ flexDirection: "row", height: 1 }}>
291
- <box style={{ width: 2, flexShrink: 0 }}>{live ? <Spinner kind="dots" color={color} /> : <text fg={color}>{mark}</text>}</box>
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>}
292
304
  <text fg={m.state === "working" ? theme.warn : theme.faint}>{crewState(m, now)}</text>
293
305
  </box>
294
306
  </box>