@capagents/forgeqe 1.1.5 → 1.1.7

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.
@@ -42,14 +42,8 @@ export type PromptProps = {
42
42
  maxRows: number
43
43
  placeholder: string
44
44
  modeColor: string
45
- profile: string
46
- model: string
47
- right?: string
48
- rightColor?: string
49
45
  /** A secret being entered: the box shows one dot per character instead of a text field, so the value is never drawn. */
50
46
  masked?: number
51
- /** Beside a session panel that names the model and profile, and a status bar: no bottom row. */
52
- compact?: boolean
53
47
  /** Text the box starts with when `inputKey` changes (a refined request, an example from the palette). */
54
48
  initialValue?: string
55
49
  /** False while an overlay (the palette, a suggestion list) takes the keyboard. Default true. */
@@ -94,7 +88,6 @@ const PROMPT_KEYS: KeyBinding[] = [
94
88
  ]
95
89
 
96
90
  export function Prompt(props: PromptProps) {
97
- const edge = `╹${"▀".repeat(Math.max(0, props.width - 1))}`
98
91
  const ref = useRef<TextareaRenderable>(null)
99
92
  // The rows the text needs; the box shows that many between minRows and maxRows, so it also grows when the screen
100
93
  // around it makes more room (the start screen folds a section for a long request).
@@ -158,20 +151,6 @@ export function Prompt(props: PromptProps) {
158
151
  )}
159
152
  </box>
160
153
  </box>
161
- {props.compact ? null : (
162
- <box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
163
- <text>
164
- <span fg={theme.muted}>{"Model "}</span>
165
- <span fg={theme.text}>{props.model || "…"}</span>
166
- </text>
167
- <text>
168
- {props.right ? <span fg={props.rightColor || theme.muted}>{props.right}</span> : null}
169
- {props.right ? <span fg={theme.faint}>{" · "}</span> : null}
170
- <span fg={theme.muted}>{"profile "}</span>
171
- <span fg={theme.text}>{props.profile || "none"}</span>
172
- </text>
173
- </box>
174
- )}
175
154
  </box>
176
155
  )
177
156
  }
@@ -301,11 +280,33 @@ export type ChoicePanelProps = {
301
280
  onHover: (index: number) => void
302
281
  /** One option per line, for many or long options. */
303
282
  vertical?: boolean
283
+ /** Rows the panel may take; a long detail is cut and a long list shows a window around the selected option. */
284
+ maxHeight?: number
285
+ }
286
+
287
+ /** The options a vertical list shows in `room` rows, around the selected one: [first, last) and what is hidden. */
288
+ export function optionWindow(count: number, selected: number, room: number): { from: number; to: number } {
289
+ if (count <= room) return { from: 0, to: count }
290
+ // Two rows go to "↑ n more" and "↓ n more"; the selected option sits in the middle of the rest when it can.
291
+ const shown = Math.max(1, room - 2)
292
+ const from = Math.max(0, Math.min(count - shown, selected - Math.floor(shown / 2)))
293
+ return { from, to: from + shown }
304
294
  }
305
295
 
306
296
  /** Question with buttons, as the agent runtime shows for permissions: arrows or tab move, enter confirms, click works too. */
307
297
  export function ChoicePanel(props: ChoicePanelProps) {
308
298
  const edge = `╹${"▀".repeat(Math.max(0, props.width - 1))}`
299
+ // The detail's columns: the bar and padding take five, its own indent two, and one more for a word that wraps early.
300
+ const inner = Math.max(10, props.width - 8)
301
+ // Padding, title, the gap, the keys row under a vertical list, and the edge below: the rest is detail and options.
302
+ const budget = Math.max(6, (props.maxHeight ?? 999) - 6 - (props.vertical ? 1 : 0))
303
+ const optionRows = props.vertical ? Math.min(props.options.length, Math.max(3, budget - 2)) : 1
304
+ const detailRoom = Math.max(1, budget - optionRows)
305
+ const detailRows = props.detail ? wrappedLines(props.detail, inner) : 0
306
+ // A detail longer than its room is cut at a word, so the options stay on screen.
307
+ const detail = detailRows > detailRoom ? `${props.detail.slice(0, detailRoom * inner - 2).replace(/\s+\S*$/, "")}…` : props.detail
308
+ const win = props.vertical ? optionWindow(props.options.length, props.selected, optionRows) : { from: 0, to: props.options.length }
309
+ const more = (n: number, arrow: string) => (n > 0 ? <text fg={theme.faint}>{` ${arrow} ${n} more`}</text> : null)
309
310
  return (
310
311
  <box style={{ flexDirection: "column", width: props.width, flexShrink: 0 }}>
311
312
  <box
@@ -326,10 +327,12 @@ export function ChoicePanel(props: ChoicePanelProps) {
326
327
  <span fg={props.color}>{"△ "}</span>
327
328
  <span fg={theme.text}>{props.title}</span>
328
329
  </text>
329
- {props.detail ? <text fg={theme.muted}>{` ${props.detail}`}</text> : null}
330
+ {detail ? <text fg={theme.muted}>{` ${detail}`}</text> : null}
330
331
  <box style={{ flexDirection: props.vertical ? "column" : "row", justifyContent: "space-between", marginTop: 1 }}>
331
332
  <box style={{ flexDirection: props.vertical ? "column" : "row", alignItems: "flex-start" }}>
332
- {props.options.map((option, i) => {
333
+ {props.vertical ? more(win.from, "↑") : null}
334
+ {props.options.slice(win.from, win.to).map((option, j) => {
335
+ const i = win.from + j
333
336
  const active = i === props.selected
334
337
  return (
335
338
  <box
@@ -342,6 +345,7 @@ export function ChoicePanel(props: ChoicePanelProps) {
342
345
  </box>
343
346
  )
344
347
  })}
348
+ {props.vertical ? more(props.options.length - win.to, "↓") : null}
345
349
  </box>
346
350
  <text style={{ marginTop: props.vertical ? 1 : 0 }}>
347
351
  <span fg={theme.text}>{props.vertical ? "↑↓" : "⇆"}</span>
@@ -6,7 +6,7 @@ import { fitButtons, Spinner, type Button } from "./components.tsx"
6
6
  import { filesByUse, type FileUse, type Inspector, type Progress } from "./session.ts"
7
7
  import { clip, tailCut } from "./text.ts"
8
8
  import { formatTokens, theme } from "./theme.ts"
9
- import type { Line } from "./transcript.tsx"
9
+ import { isMarker, type Line } from "./transcript.tsx"
10
10
 
11
11
  /**
12
12
  * The split view's panels. Each is a rounded box with its title in the border, in its section's colour from the
@@ -66,41 +66,6 @@ export function Panel(props: { title: string; color: string; width?: number; hei
66
66
  )
67
67
  }
68
68
 
69
- export type SessionInfo = {
70
- /** "gpt-5.4 (azure-gpt54)": the model, and the llms.json profile it comes from. */
71
- model: string
72
- cwd: string
73
- domain?: string
74
- started?: number
75
- requests: number
76
- }
77
-
78
- /** Rows SessionPanel takes, border included. */
79
- export function sessionRows(info: SessionInfo): number {
80
- return 3 + (info.domain ? 1 : 0) + PANEL_CHROME
81
- }
82
-
83
- /** Top of the left pane: what the session works with, nothing more; tokens are in their own panel. */
84
- export function SessionPanel({ info, width }: { info: SessionInfo; width: number }) {
85
- const room = Math.max(10, width - 15)
86
- const row = (label: string, value: string, color: string = theme.text) => (
87
- <text key={label}>
88
- <span fg={theme.muted}>{label.padEnd(10)}</span>
89
- <span fg={color}>{clip(value, room)}</span>
90
- </text>
91
- )
92
- const started = info.started ? new Date(info.started) : null
93
- const since = started ? ` · since ${String(started.getHours()).padStart(2, "0")}:${String(started.getMinutes()).padStart(2, "0")}` : ""
94
- return (
95
- <Panel title={`Session${since}`} color={SECTION.session} width={width}>
96
- {row("Model", info.model)}
97
- {row("Folder", tailCut(info.cwd, room))}
98
- {info.domain ? row("Domain", info.domain, theme.memory) : null}
99
- {row("Requests", String(info.requests), theme.muted)}
100
- </Panel>
101
- )
102
- }
103
-
104
69
  /** The right pane's first line: who is working on what and for how long, and Stop while something runs. */
105
70
  /** How far the agent's own plan has got, while it has one of two steps or more. */
106
71
  export function planProgress(s: Inspector): { done: number; total: number } | null {
@@ -113,6 +78,13 @@ export function progressBar(done: number, total: number, cells: number): [string
113
78
  return ["█".repeat(filled), "░".repeat(cells - filled)]
114
79
  }
115
80
 
81
+ /** Rows the status bar takes: who and how far on the first, what is happening on the second. */
82
+ export const STATUS_ROWS = 2
83
+
84
+ /**
85
+ * The Activities column's first lines. The first: who is working, how far a plan or suite has got, for how long, and
86
+ * Stop. The second, the whole width: what is happening now, so it is never squeezed out by the rest.
87
+ */
116
88
  export function StatusBar(props: {
117
89
  running: boolean
118
90
  who: string
@@ -121,16 +93,16 @@ export function StatusBar(props: {
121
93
  status: string
122
94
  width: number
123
95
  onStop: () => void
124
- /** A suite's cases: done so far, of how many. */
96
+ /** A suite's cases or the plan's tasks: done so far, of how many. */
125
97
  progress?: { done: number; total: number } | null
126
- /** Buttons for the view (Panels, Wide chat), before Stop; the first to give way when the line is short. */
98
+ /** Buttons for the view (Focus panels, Close), before Stop; the first to give way when the line is short. */
127
99
  actions?: Button[]
128
100
  }) {
129
101
  const done = props.status === "done"
130
102
  const failed = props.status === "fail"
131
103
  const color = props.running ? theme.warn : done ? theme.ok : failed ? theme.err : theme.muted
132
- const what = props.running
133
- ? props.activity || "working…"
104
+ const state = props.running
105
+ ? props.who
134
106
  : done
135
107
  ? "Last request done"
136
108
  : failed
@@ -141,45 +113,47 @@ export function StatusBar(props: {
141
113
  const bar = props.running && props.progress && props.progress.total > 1 ? props.progress : null
142
114
  const cells = 12
143
115
  const barText = bar ? ` ${bar.done}/${bar.total} ` : ""
144
- const fixed = props.who.length + (props.running ? 30 : 6) + (bar ? cells + barText.length : 0)
145
- const actions = fitButtons(props.actions || [], Math.max(0, props.width - fixed - 28))
146
- const room = Math.max(10, props.width - fixed - actions.reduce((n, b) => n + b.icon.length + b.label.length + (b.keys ? b.keys.length + 1 : 0) + 4, 0))
116
+ const stop = "■ Stop · esc"
117
+ const fixed = state.length + 4 + (props.running ? stop.length + 4 + props.elapsed.length + 2 + (bar ? cells + barText.length : 0) : 0)
118
+ const actions = fitButtons(props.actions || [], Math.max(0, props.width - fixed - 2))
147
119
  const [full, empty] = bar ? progressBar(bar.done, bar.total, cells) : ["", ""]
148
120
  return (
149
- <box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
150
- <box style={{ flexDirection: "row" }}>
151
- {props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={color}>{"●"}</text>}
152
- <text>
153
- {props.running ? <span fg={theme.text}>{` ${props.who}`}</span> : null}
154
- <span fg={props.running ? theme.muted : color}>{`${props.running ? " " : " "}${clip(what, room)}`}</span>
155
- </text>
156
- </box>
157
- <box style={{ flexDirection: "row" }}>
158
- {actions.map((b) => (
159
- <box key={b.label} onMouseDown={b.onPress} style={{ flexDirection: "row", paddingLeft: 1, paddingRight: 1, marginLeft: 1, backgroundColor: theme.button }}>
160
- <text>
161
- <span fg={b.color}>{`${b.icon} `}</span>
162
- <span fg={theme.text}>{b.label}</span>
163
- {b.keys ? <span fg={theme.faint}>{` ${b.keys}`}</span> : null}
164
- </text>
165
- </box>
166
- ))}
167
- {props.running ? (
168
- <box style={{ flexDirection: "row", marginLeft: 2 }}>
169
- {bar ? (
170
- <text>
171
- <span fg={theme.ok}>{full}</span>
172
- <span fg={theme.border}>{empty}</span>
173
- <span fg={theme.muted}>{barText}</span>
174
- </text>
121
+ <box style={{ flexDirection: "column", height: STATUS_ROWS, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
122
+ <box style={{ flexDirection: "row", justifyContent: "space-between", height: 1 }}>
123
+ <box style={{ flexDirection: "row" }}>
124
+ {props.running ? <Spinner kind="dots" color={theme.warn} /> : <text fg={color}>{"●"}</text>}
125
+ <text fg={props.running ? theme.text : color}>{` ${clip(state, Math.max(8, props.width - fixed + state.length))}`}</text>
126
+ </box>
127
+ <box style={{ flexDirection: "row" }}>
128
+ {actions.map((b) => (
129
+ <box key={b.label} onMouseDown={b.onPress} style={{ flexDirection: "row", paddingLeft: 1, paddingRight: 1, marginLeft: 1, backgroundColor: theme.button }}>
130
+ <text>
131
+ <span fg={b.color}>{`${b.icon} `}</span>
132
+ <span fg={theme.text}>{b.label}</span>
133
+ {b.keys ? <span fg={theme.faint}>{` ${b.keys}`}</span> : null}
134
+ </text>
135
+ </box>
136
+ ))}
137
+ {props.running ? (
138
+ <box style={{ flexDirection: "row", marginLeft: 2 }}>
139
+ {bar ? (
140
+ <text>
141
+ <span fg={theme.ok}>{full}</span>
142
+ <span fg={theme.border}>{empty}</span>
143
+ <span fg={theme.muted}>{barText}</span>
144
+ </text>
145
+ ) : null}
146
+ <text fg={theme.faint}>{`${props.elapsed} `}</text>
147
+ <box onMouseDown={props.onStop} style={{ paddingLeft: 1, paddingRight: 1, backgroundColor: theme.warn }}>
148
+ <text fg={theme.onButton}>{stop}</text>
149
+ </box>
150
+ </box>
175
151
  ) : null}
176
- <text fg={theme.faint}>{`${props.elapsed} `}</text>
177
- <box onMouseDown={props.onStop} style={{ paddingLeft: 1, paddingRight: 1, backgroundColor: theme.warn }}>
178
- <text fg={theme.onButton}>{"■ Stop · esc"}</text>
179
- </box>
180
152
  </box>
181
- ) : null}
182
153
  </box>
154
+ <text fg={props.running ? theme.muted : theme.faint} style={{ height: 1 }}>
155
+ {` ${clip(props.running ? props.activity || "Working…" : "", Math.max(10, props.width - 4))}`}
156
+ </text>
183
157
  </box>
184
158
  )
185
159
  }
@@ -197,18 +171,19 @@ const PROGRESS_MARK: Record<Progress["status"], [string, string]> = {
197
171
  skipped: ["■", theme.faint],
198
172
  }
199
173
 
200
- export type PlanLine = { mark: string; color: string; text: string; textColor: string }
174
+ /** `live`: a task in progress or a step running, drawn with a spinner while the request runs. */
175
+ export type PlanLine = { mark: string; color: string; text: string; textColor: string; live?: boolean }
201
176
 
202
177
  export function planLines(s: Inspector): PlanLine[] {
203
178
  const lines: PlanLine[] = []
204
179
  for (const p of s.plan) {
205
180
  const [mark, color] = PLAN_MARK[p.status] || PLAN_MARK.pending!
206
- lines.push({ mark, color, text: p.text, textColor: p.status === "completed" || p.status === "cancelled" ? theme.muted : theme.text })
181
+ lines.push({ mark, color, text: p.text, textColor: p.status === "completed" || p.status === "cancelled" ? theme.muted : theme.text, ...(p.status === "in_progress" ? { live: true } : {}) })
207
182
  }
208
183
  if (s.progress.items.length && s.plan.length) lines.push({ mark: "─", color: theme.border, text: s.progress.title, textColor: theme.faint })
209
184
  for (const i of s.progress.items) {
210
185
  const [mark, color] = PROGRESS_MARK[i.status]
211
- lines.push({ mark, color, text: i.detail ? `${i.label} — ${i.detail}` : i.label, textColor: i.status === "fail" ? theme.err : i.status === "running" ? theme.text : theme.muted })
186
+ lines.push({ mark, color, text: i.detail ? `${i.label} — ${i.detail}` : i.label, textColor: i.status === "fail" ? theme.err : i.status === "running" ? theme.text : theme.muted, ...(i.status === "running" ? { live: true } : {}) })
212
187
  }
213
188
  return lines
214
189
  }
@@ -227,7 +202,7 @@ export function windowRows(total: number, rows: number, selected?: number): [num
227
202
  }
228
203
 
229
204
  /** The agent's to-do list, then the browser steps or suite cases; the latest that fit, or around the selected one. */
230
- export function PlanPanel(props: { s: Inspector; width: number; height: number; selected?: number; onFocus?: () => void }) {
205
+ export function PlanPanel(props: { s: Inspector; width: number; height: number; selected?: number; onFocus?: () => void; animate?: boolean }) {
231
206
  const { s, width, height } = props
232
207
  const lines = planLines(s)
233
208
  const done = s.plan.filter((p) => p.status === "completed").length
@@ -239,10 +214,9 @@ export function PlanPanel(props: { s: Inspector; width: number; height: number;
239
214
  const active = first + i === props.selected
240
215
  return (
241
216
  <box key={first + i} style={{ flexDirection: "row", height: 1, flexShrink: 0, backgroundColor: active ? theme.button : undefined }}>
242
- <text>
243
- <span fg={l.color}>{`${l.mark} `}</span>
244
- <span fg={active ? theme.text : l.textColor}>{clip(l.text, Math.max(10, width - 8))}</span>
245
- </text>
217
+ {/* What is being worked on now spins, so it reads as live; a task left open after the request does not. */}
218
+ <box style={{ width: 2, flexShrink: 0 }}>{l.live && props.animate ? <Spinner kind="dots" color={l.color} /> : <text fg={l.color}>{l.mark}</text>}</box>
219
+ <text fg={active ? theme.text : l.textColor}>{clip(l.text, Math.max(10, width - 8))}</text>
246
220
  </box>
247
221
  )
248
222
  })}
@@ -252,7 +226,9 @@ export function PlanPanel(props: { s: Inspector; width: number; height: number;
252
226
 
253
227
  /** Each kind of activity line: its mark, the mark's colour, and the text's colour. */
254
228
  const ACTIVITY_STYLE: Record<string, [string, string, string]> = {
255
- tool: ["⚙", theme.faint, theme.muted],
229
+ running: ["◐", theme.warn, theme.text],
230
+ // A finished call: ✓, so it never reads as the ○ of a task still to do.
231
+ tool: ["✓", theme.ok, theme.muted],
256
232
  file: ["✎", theme.accent, theme.text],
257
233
  code: ["▸", theme.memory, theme.memory],
258
234
  timing: ["+", theme.warn, theme.warn],
@@ -268,23 +244,85 @@ export function toolArgs(text: string): [string, string] | null {
268
244
  return at > 0 && text.endsWith(")") ? [text.slice(0, at), text.slice(at + 1)] : null
269
245
  }
270
246
 
271
- /** One activity line, its mark in a column of its own so wrapped text stays under the text. */
272
- export function ActivityRow({ line }: { line: Line }) {
247
+ /** A call on one row: its name in full where it fits, then as much of its arguments as is left, dimmed. */
248
+ function ToolCall({ parts: [name, args], width }: { parts: [string, string]; width: number }) {
249
+ const shown = width > 0 ? clip(name, width) : name
250
+ const left = width > 0 ? width - shown.length - 1 : Infinity
251
+ return (
252
+ <text style={{ flexGrow: 1, flexShrink: 1, height: width > 0 ? 1 : undefined }}>
253
+ <span fg={theme.text}>{shown}</span>
254
+ {left >= 4 ? <span fg={theme.faint}>{` ${width > 0 ? clip(args, left) : args}`}</span> : null}
255
+ </text>
256
+ )
257
+ }
258
+
259
+ const ENDED: Record<NonNullable<Line["ended"]>, [string, string, string]> = {
260
+ done: ["✓", "Done", theme.ok],
261
+ fail: ["✗", "Failed", theme.err],
262
+ stopped: ["■", "Stopped", theme.warn],
263
+ idle: ["?", "Waiting for your answer", theme.warn],
264
+ }
265
+
266
+ /** "── 14:02 can you generate a defect report… ─────": where a request's activities start. */
267
+ function RequestStart({ line, width, first }: { line: Line; width: number; first: boolean }) {
268
+ const d = new Date(line.at || Date.now())
269
+ const time = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
270
+ // The rule runs the whole row: the mark column and the text's.
271
+ const room = Math.max(12, (width || 60) + 3)
272
+ const text = clip(line.text, Math.max(8, room - time.length - 8))
273
+ const fill = Math.max(2, room - 3 - time.length - 2 - text.length - 1)
274
+ return (
275
+ <text style={{ height: 1, flexShrink: 0, marginTop: first ? 0 : 1 }}>
276
+ <span fg={theme.border}>{"── "}</span>
277
+ <span fg={theme.accent}>{time}</span>
278
+ <span fg={theme.text}>{` ${text} `}</span>
279
+ <span fg={theme.border}>{"─".repeat(fill)}</span>
280
+ </text>
281
+ )
282
+ }
283
+
284
+ /** "✓ Done in 1m 12s · 16 activities": how a request ended. */
285
+ function RequestEnd({ line, width }: { line: Line; width: number }) {
286
+ const [mark, label, color] = ENDED[line.ended || "done"]
287
+ return (
288
+ <text style={{ height: 1, flexShrink: 0 }}>
289
+ <span fg={color}>{`${mark} ${label}`}</span>
290
+ <span fg={theme.faint}>{` ${clip(line.text, Math.max(8, (width || 60) - label.length))}`}</span>
291
+ </text>
292
+ )
293
+ }
294
+
295
+ /** Rows an activity line may take: a call is one row; narration and results up to three. */
296
+ const NOTE_ROWS = 3
297
+
298
+ /**
299
+ * One activity line, its mark in a column of its own so wrapped text stays under the text. `width` is the text's
300
+ * columns: a call is cut to one row and anything else to three, so a burst of output never takes the panel over.
301
+ */
302
+ export function ActivityRow({ line, width = 0, first = false }: { line: Line; width?: number; first?: boolean }) {
303
+ if (line.kind === "request") return <RequestStart line={line} width={width} first={first} />
304
+ if (line.kind === "requestEnd") return <RequestEnd line={line} width={width} />
273
305
  const [mark, markColor, textColor] = ACTIVITY_STYLE[line.kind] || ["·", theme.faint, theme.muted]
306
+ const oneRow = line.kind === "tool" || line.kind === "running" || line.kind === "file"
307
+ const fit = (t: string, rows: number) => (width > 0 ? clip(t.replace(/\s+/g, " "), width * rows) : t)
274
308
  return (
275
309
  <box style={{ flexDirection: "row", flexShrink: 0 }}>
276
- <text fg={markColor} style={{ width: 2, flexShrink: 0 }}>
277
- {mark}
278
- </text>
279
- {line.kind === "tool" && toolArgs(line.text) ? (
280
- // "Azure DevOps · pipelines build (action list, project QI-Claims)": the call readable, its arguments quiet.
281
- <text style={{ flexGrow: 1, flexShrink: 1 }}>
282
- <span fg={theme.text}>{toolArgs(line.text)![0]}</span>
283
- <span fg={theme.faint}>{` ${toolArgs(line.text)![1]}`}</span>
310
+ {line.kind === "running" ? (
311
+ // A call in progress spins until its result takes the row.
312
+ <box style={{ width: 2, flexShrink: 0 }}>
313
+ <Spinner kind="dots" color={markColor} />
314
+ </box>
315
+ ) : (
316
+ <text fg={markColor} style={{ width: 2, flexShrink: 0 }}>
317
+ {mark}
284
318
  </text>
319
+ )}
320
+ {(line.kind === "tool" || line.kind === "running") && toolArgs(line.text) ? (
321
+ // "Azure DevOps · pipelines build (action list, project QI-Claims)": the call readable, its arguments quiet.
322
+ <ToolCall parts={toolArgs(line.text)!} width={width} />
285
323
  ) : (
286
324
  <text fg={textColor} style={{ flexGrow: 1, flexShrink: 1 }}>
287
- {line.text || " "}
325
+ {fit(line.text, oneRow ? 1 : NOTE_ROWS) || " "}
288
326
  </text>
289
327
  )}
290
328
  </box>
@@ -296,13 +334,35 @@ export const ACTIVITY_FILTERS = [
296
334
  { id: "all", label: "All", key: "a", kinds: null },
297
335
  { id: "errors", label: "Errors", key: "e", kinds: ["err"] },
298
336
  { id: "steps", label: "Steps", key: "s", kinds: ["ok", "err", "thought", "memory", "timing"] },
299
- { id: "tools", label: "Tools", key: "t", kinds: ["tool", "file", "code"] },
337
+ { id: "tools", label: "Tools", key: "t", kinds: ["running", "tool", "file", "code"] },
300
338
  ] as const
301
339
  export type ActivityFilter = (typeof ACTIVITY_FILTERS)[number]["id"]
302
340
 
341
+ /** The lines a filter keeps; where each request starts and ends always stays, so a filtered line is never orphaned. */
303
342
  export function filterActivity(lines: Line[], filter: ActivityFilter): Line[] {
304
343
  const kinds = ACTIVITY_FILTERS.find((f) => f.id === filter)?.kinds
305
- return kinds ? lines.filter((l) => (kinds as readonly string[]).includes(l.kind)) : lines
344
+ return kinds ? lines.filter((l) => isMarker(l) || (kinds as readonly string[]).includes(l.kind)) : lines
345
+ }
346
+
347
+ /**
348
+ * Where the work stands, from the agent's own plan and the run's steps: the task in progress (or the step running) and
349
+ * the next task. Nothing new is planned here; with no plan and no steps there is nothing to say.
350
+ */
351
+ export function nowNext(s: Inspector): { now?: string; next?: string; done: number; total: number } {
352
+ const at = s.plan.findIndex((p) => p.status === "in_progress")
353
+ const step = [...s.progress.items].reverse().find((i) => i.status === "running")
354
+ const pending = s.plan.find((p, i) => p.status === "pending" && i > at)
355
+ return {
356
+ now: at >= 0 ? s.plan[at]!.text : step?.label,
357
+ next: pending?.text,
358
+ done: s.plan.filter((p) => p.status === "completed").length,
359
+ total: s.plan.length,
360
+ }
361
+ }
362
+
363
+ /** Rows the Now/Next header takes: one for each it has. */
364
+ export function nowNextRows(n: ReturnType<typeof nowNext>): number {
365
+ return (n.now ? 1 : 0) + (n.next ? 1 : 0)
306
366
  }
307
367
 
308
368
  /** Everything the agents do, newest at the bottom; focused, it scrolls and narrows to errors, steps, or tools. */
@@ -315,11 +375,16 @@ export function ActivityPanel(props: {
315
375
  focused?: boolean
316
376
  onFocus?: () => void
317
377
  onFilter?: (f: ActivityFilter) => void
378
+ /** The task in progress and the next one, above the lines. */
379
+ now?: ReturnType<typeof nowNext>
318
380
  }) {
319
381
  const filter = props.filter || "all"
382
+ const room = Math.max(10, props.width - 12)
320
383
  const shown = filterActivity(props.lines, filter)
321
384
  const label = ACTIVITY_FILTERS.find((f) => f.id === filter)!.label
322
- const count = filter === "all" ? (shown.length ? ` · ${shown.length}` : "") : ` · ${label} ${shown.length} of ${props.lines.length}`
385
+ // Counts are of activities: the lines marking where requests start and end are not.
386
+ const real = (ls: Line[]) => ls.filter((l) => !isMarker(l)).length
387
+ const count = filter === "all" ? (real(shown) ? ` · ${real(shown)}` : "") : ` · ${label} ${real(shown)} of ${real(props.lines)}`
323
388
  return (
324
389
  <Panel title={`Activity${count}`} color={SECTION.activity} width={props.width} height={props.height} focused={props.focused} keys="↑↓ pgup pgdn scroll · esc chat" onFocus={props.onFocus}>
325
390
  {props.focused || filter !== "all" ? (
@@ -337,16 +402,32 @@ export function ActivityPanel(props: {
337
402
  })}
338
403
  </box>
339
404
  ) : null}
340
- {shown.length ? (
405
+ {props.now?.now ? (
406
+ <box style={{ flexDirection: "row", height: 1, flexShrink: 0 }}>
407
+ <text fg={theme.muted}>{"Now "}</text>
408
+ <box style={{ width: 2, flexShrink: 0 }}>
409
+ <Spinner kind="dots" color={theme.warn} />
410
+ </box>
411
+ <text fg={theme.text}>{clip(props.now.now, room)}</text>
412
+ </box>
413
+ ) : null}
414
+ {props.now?.next ? (
415
+ <text style={{ height: 1, flexShrink: 0 }}>
416
+ <span fg={theme.muted}>{"Next "}</span>
417
+ <span fg={theme.faint}>{"○ "}</span>
418
+ <span fg={theme.muted}>{clip(props.now.next, room)}</span>
419
+ </text>
420
+ ) : null}
421
+ {real(shown) ? (
341
422
  <scrollbox ref={props.scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1 }} focused={false}>
342
- {shown.map((line) => (
343
- <ActivityRow key={line.id} line={line} />
423
+ {shown.map((line, i) => (
424
+ <ActivityRow key={line.id} line={line} width={Math.max(10, props.width - 8)} first={i === 0} />
344
425
  ))}
345
426
  </scrollbox>
346
427
  ) : (
347
428
  <box style={{ flexGrow: 1, justifyContent: "center", alignItems: "center" }}>
348
429
  <text fg={theme.faint}>
349
- {clip(props.lines.length ? `Nothing under ${label} yet` : "Tool calls, browser steps, and the agents' reasoning show here", Math.max(10, props.width - 4))}
430
+ {clip(real(props.lines) ? `Nothing under ${label} yet` : "Tool calls, browser steps, and the agents' reasoning show here", Math.max(10, props.width - 4))}
350
431
  </text>
351
432
  </box>
352
433
  )}
@@ -450,3 +531,30 @@ export function TokensPanel(props: { total: Usage; width: number; height: number
450
531
  </Panel>
451
532
  )
452
533
  }
534
+
535
+ /**
536
+ * Files, context, and tokens in one line, for a column too narrow for their three panels:
537
+ * "Files 4 (1 written) · Context 26% · Skills 2 · 52.9K tokens".
538
+ */
539
+ export function InfoStrip(props: { s: Inspector; c: ContextInfo; total: Usage; width: number }) {
540
+ const files = filesByUse(props.s)
541
+ const changed = files.filter((f) => f.changes).length
542
+ const share = props.c.limit ? Math.round(Math.min(1, props.c.tokens / props.c.limit) * 100) : 0
543
+ const parts: [string, string, string][] = [
544
+ ["Files ", `${files.length}${changed ? ` (${changed} changed)` : ""}`, SECTION.files],
545
+ ["Context ", `${share}%`, share < 60 ? theme.ok : share < 85 ? theme.warn : theme.err],
546
+ ...(props.c.skills.length ? [["Skills ", String(props.c.skills.length), SECTION.context] as [string, string, string]] : []),
547
+ ["", `${formatTokens(props.total.input + props.total.output)} tokens`, SECTION.tokens],
548
+ ]
549
+ return (
550
+ <box style={{ flexDirection: "row", height: 1, flexShrink: 0, width: props.width, paddingLeft: 1 }}>
551
+ <text>
552
+ {parts.flatMap(([label, value, color], i) => [
553
+ <span key={`s${i}`} fg={theme.faint}>{i ? " · " : ""}</span>,
554
+ <span key={`l${i}`} fg={theme.muted}>{label}</span>,
555
+ <span key={`v${i}`} fg={color}>{value}</span>,
556
+ ])}
557
+ </text>
558
+ </box>
559
+ )
560
+ }
@@ -75,9 +75,10 @@ export type Fact = { text: string; tone: "name" | "plain" | "warn" }
75
75
 
76
76
  /**
77
77
  * The project line: the folder, its stack, each system ForgeQE works with here and how it is signed in ("Azure DevOps
78
- * (PAT)"), and the model; or, where nothing is set up, that this folder has no forgeqe.yaml.
78
+ * (PAT)"); or, where nothing is set up, that this folder has no forgeqe.yaml. The model and profile are the status
79
+ * line's, and a missing model is Setup's, so neither is repeated here.
79
80
  */
80
- export function projectLine(cfg: AppConfig | null, cwd: string, opts: { model: string; signIns?: Record<string, string> }): Fact[] {
81
+ export function projectLine(cfg: AppConfig | null, cwd: string, opts: { signIns?: Record<string, string> } = {}): Fact[] {
81
82
  const facts: Fact[] = [{ text: path.basename(cwd), tone: "name" }, ...projectStack(cwd).map((t) => ({ text: t, tone: "plain" as const }))]
82
83
  if (!cfg) return facts
83
84
  if (!cfg.configPath) return [...facts, { text: "no forgeqe.yaml here", tone: "warn" }]
@@ -86,8 +87,6 @@ export function projectLine(cfg: AppConfig | null, cwd: string, opts: { model: s
86
87
  const how = opts.signIns?.[SYSTEM_LABEL[s]]
87
88
  facts.push({ text: how ? `${SYSTEM_LABEL[s]} (${how})` : SYSTEM_LABEL[s], tone: "plain" })
88
89
  }
89
- const live = cfg.llm.provider !== "none" && cfg.llm.provider !== "mock"
90
- facts.push(live && opts.model ? { text: opts.model, tone: "plain" } : { text: "no model", tone: "warn" })
91
90
  return facts
92
91
  }
93
92