@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.
@@ -1,5 +1,8 @@
1
+ import type { ScrollBoxRenderable } from "@opentui/core"
2
+ import type { RefObject } from "react"
3
+
1
4
  import type { Refined } from "../refine.ts"
2
- import { Spinner } from "./components.tsx"
5
+ import { Spinner, wrappedLines } from "./components.tsx"
3
6
  import { formatDuration, formatTokens, theme } from "./theme.ts"
4
7
 
5
8
  /** Refine's panel, in the prompt's place: working, the result with what was checked, or why it could not refine. */
@@ -27,35 +30,72 @@ export function initialChoice(missing: string[]): number {
27
30
  return REFINE_CHOICES.findIndex((c) => c.id === (missing.length ? "edit" : "use"))
28
31
  }
29
32
 
30
- export function RefinePanel(props: { state: RefineState; width: number; onChoose: (id: RefineChoice) => void; onHover: (i: number) => void }) {
33
+ /** A row of the refined request as the panel draws it: a label in the panel's colour, a ✓ for a checked fact, or plain. */
34
+ type Row = { text: string; mark?: string; markColor?: string; label?: string; color?: string; heading?: boolean }
35
+
36
+ /** Everything the panel's body shows, in order: what is still missing first, since it decides whether to send. */
37
+ export function refineRows(r: Refined): Row[] {
38
+ const contextLines = r.refined.split("\n").filter((l) => l.startsWith("- ")).map((l) => norm(l.slice(2)))
39
+ const inContext = new Set(contextLines)
40
+ // Facts Refine checked that are also context lines are marked there, not repeated under Checked.
41
+ const checked = new Set(r.grounded.filter((g) => g.checked).map((g) => norm(g.fact)).filter((f) => inContext.has(f)))
42
+ const section = (label: string, mark: string, markColor: string, items: string[]): Row[] =>
43
+ items.length ? [{ text: label, heading: true, color: markColor === theme.err ? theme.err : theme.muted }, ...items.map((text) => ({ text, mark, markColor })), { text: "" }] : []
44
+ const request: Row[] = r.refined.split("\n").map((line) => {
45
+ if (line.startsWith("- ") && checked.has(norm(line.slice(2)))) return { text: line.slice(2), mark: "✓", markColor: theme.ok }
46
+ const label = /^(Role|Objective|Context|Steps|Rules|Output format|Summary|Goal|Result):/.exec(line)
47
+ return label ? { text: line.slice(label[0].length), label: label[0] } : { text: line }
48
+ })
49
+ return [
50
+ ...section("Missing — answer these in your request", "?", theme.err, r.missing),
51
+ ...request,
52
+ ...section("Checked", "✓", theme.ok, r.grounded.filter((g) => g.checked && !inContext.has(norm(g.fact))).map((g) => g.fact)),
53
+ ...section("Assumed", "~", theme.warn, [...r.grounded.filter((g) => !g.checked).map((g) => g.fact), ...r.assumed]),
54
+ ]
55
+ }
56
+
57
+ /** The screen rows `rows` take at `width` columns, wrapped as the panel wraps them. */
58
+ export function rowsHeight(rows: Row[], width: number): number {
59
+ return rows.reduce((n, r) => n + (r.text ? wrappedLines((r.label || "") + r.text, width - (r.mark ? 2 : 0)) : 1), 0)
60
+ }
61
+
62
+ /**
63
+ * Refine's panel, in the prompt's place. `maxHeight` is the rows it may take with its action row; a longer request
64
+ * scrolls inside it (↑↓ pgup pgdn, or the wheel) while the title and the actions stay on screen.
65
+ */
66
+ export function RefinePanel(props: {
67
+ state: RefineState
68
+ width: number
69
+ maxHeight: number
70
+ scrollRef?: RefObject<ScrollBoxRenderable | null>
71
+ onChoose: (id: RefineChoice) => void
72
+ onHover: (i: number) => void
73
+ }) {
31
74
  const { state, width } = props
32
75
  const cost = state.phase === "done" && state.tokens ? ` · ${formatTokens(state.tokens)} tokens${state.ms ? ` · ${formatDuration(state.ms)}` : ""}` : ""
33
76
  // How much of the request keeps the ASD-STE100 rules, as Refine's prompt asks.
34
77
  const ste = state.phase === "done" && state.result.ste !== undefined ? ` · STE ${Math.round(state.result.ste * 100)}%` : ""
35
- const title = state.phase === "working" ? " ✦ Refining " : state.phase === "error" ? " ✦ Could not refine " : ` ✦ Refined request${ste}${cost} `
36
- // Facts Refine checked that are also context lines are marked there, not repeated under Checked.
37
- const contextLines = state.phase === "done" ? state.result.refined.split("\n").filter((l) => l.startsWith("- ")).map((l) => norm(l.slice(2))) : []
38
- const inContext = new Set(contextLines)
39
- const checked = new Set(state.phase === "done" ? state.result.grounded.filter((g) => g.checked).map((g) => norm(g.fact)).filter((f) => inContext.has(f)) : [])
40
- const section = (label: string, color: string, mark: string, items: string[]) =>
41
- items.length ? (
42
- <box style={{ flexDirection: "column", marginTop: 1 }}>
43
- <text fg={theme.muted}>{label}</text>
44
- {items.map((it, i) => (
45
- <box key={i} style={{ flexDirection: "row" }}>
46
- <text fg={color} style={{ width: 2, flexShrink: 0 }}>
47
- {mark}
48
- </text>
49
- <text fg={theme.text} style={{ flexGrow: 1, flexShrink: 1 }}>
50
- {it}
51
- </text>
52
- </box>
53
- ))}
54
- </box>
55
- ) : null
78
+ const missing = state.phase === "done" && state.result.missing.length ? ` · ${state.result.missing.length} to answer` : ""
79
+ const title = state.phase === "working" ? " ✦ Refining " : state.phase === "error" ? " ✦ Could not refine " : ` ✦ Refined request${missing}${ste}${cost} `
80
+ const rows = state.phase === "done" ? refineRows(state.result) : []
81
+ const inner = Math.max(10, width - 4)
82
+ // The border takes two rows and the action row one; the body gets the rest, and scrolls when it needs more.
83
+ const room = Math.max(3, props.maxHeight - 3)
84
+ const needed = rowsHeight(rows, inner)
85
+ const scrolls = needed > room
86
+ const bodyHeight = Math.min(needed, room)
56
87
  return (
57
88
  <box style={{ flexDirection: "column", width, flexShrink: 0 }}>
58
- <box border borderStyle="rounded" borderColor={COLOR} title={title} titleColor={COLOR} style={{ flexDirection: "column", paddingLeft: 1, paddingRight: 1, backgroundColor: theme.panel }}>
89
+ <box
90
+ border
91
+ borderStyle="rounded"
92
+ borderColor={COLOR}
93
+ title={title}
94
+ titleColor={COLOR}
95
+ bottomTitle={scrolls ? " ↑↓ pgup pgdn scroll " : undefined}
96
+ bottomTitleAlignment="right"
97
+ style={{ flexDirection: "column", paddingLeft: 1, paddingRight: 1, backgroundColor: theme.panel, flexShrink: 0 }}
98
+ >
59
99
  {state.phase === "working" ? (
60
100
  <box style={{ flexDirection: "row" }}>
61
101
  <Spinner kind="dots" color={COLOR} />
@@ -64,37 +104,36 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
64
104
  ) : state.phase === "error" ? (
65
105
  <text fg={theme.err}>{state.message}</text>
66
106
  ) : (
67
- <>
68
- {/* The request as it is sent: its labelled parts (Role, Objective, Context, Steps, Rules, Output format, Summary) stand out. */}
69
- {state.result.refined.split("\n").map((line, i) => {
70
- const label = /^(Role|Objective|Context|Steps|Rules|Output format|Summary|Goal|Result):/.exec(line)
71
- // A context line Refine checked against the systems: ✓ in place of its dash, so it is not listed twice.
72
- if (line.startsWith("- ") && checked.has(norm(line.slice(2)))) {
73
- return (
74
- <text key={i}>
75
- <span fg={theme.ok}>{"✓ "}</span>
76
- <span fg={theme.text}>{line.slice(2)}</span>
107
+ <scrollbox ref={props.scrollRef} style={{ height: bodyHeight, flexShrink: 0 }} focused={false}>
108
+ {rows.map((r, i) =>
109
+ r.heading ? (
110
+ <text key={i} fg={r.color}>
111
+ {r.text}
112
+ </text>
113
+ ) : r.mark ? (
114
+ <box key={i} style={{ flexDirection: "row", flexShrink: 0 }}>
115
+ <text fg={r.markColor} style={{ width: 2, flexShrink: 0 }}>
116
+ {r.mark}
117
+ </text>
118
+ <text fg={theme.text} style={{ flexGrow: 1, flexShrink: 1 }}>
119
+ {r.text}
77
120
  </text>
78
- )
79
- }
80
- return label ? (
121
+ </box>
122
+ ) : r.label ? (
81
123
  <text key={i}>
82
- <span fg={COLOR}>{label[0]}</span>
83
- <span fg={theme.text}>{line.slice(label[0].length)}</span>
124
+ <span fg={COLOR}>{r.label}</span>
125
+ <span fg={theme.text}>{r.text}</span>
84
126
  </text>
85
127
  ) : (
86
- <text key={i} fg={line.trim() ? theme.text : undefined}>
87
- {line || " "}
128
+ <text key={i} fg={theme.text}>
129
+ {r.text || " "}
88
130
  </text>
89
- )
90
- })}
91
- {section("Checked", theme.ok, "✓", state.result.grounded.filter((g) => g.checked && !inContext.has(norm(g.fact))).map((g) => g.fact))}
92
- {section("Assumed", theme.warn, "~", [...state.result.grounded.filter((g) => !g.checked).map((g) => g.fact), ...state.result.assumed])}
93
- {section("Missing — answer these in your request", theme.err, "?", state.result.missing)}
94
- </>
131
+ ),
132
+ )}
133
+ </scrollbox>
95
134
  )}
96
135
  </box>
97
- <box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, marginTop: 0 }}>
136
+ <box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0 }}>
98
137
  {state.phase === "done" ? (
99
138
  <box style={{ flexDirection: "row" }}>
100
139
  {REFINE_CHOICES.map((c, i) => {
@@ -112,12 +151,23 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
112
151
  <text>
113
152
  {state.phase === "done" ? (
114
153
  <>
154
+ <span fg={theme.text}>{"⇆"}</span>
155
+ <span fg={theme.muted}>{" choose "}</span>
115
156
  <span fg={theme.text}>{"enter"}</span>
116
157
  <span fg={theme.muted}>{` ${REFINE_CHOICES[state.choice]!.id === "keep" ? "keep mine" : REFINE_CHOICES[state.choice]!.id} `}</span>
117
- <span fg={theme.text}>{"e"}</span>
118
- <span fg={theme.muted}>{" edit "}</span>
119
- <span fg={theme.text}>{"esc"}</span>
120
- <span fg={theme.muted}>{" keep mine"}</span>
158
+ {/* Each action's key once: e and esc are not repeated when enter already does them. */}
159
+ {REFINE_CHOICES[state.choice]!.id !== "edit" ? (
160
+ <>
161
+ <span fg={theme.text}>{"e"}</span>
162
+ <span fg={theme.muted}>{" edit "}</span>
163
+ </>
164
+ ) : null}
165
+ {REFINE_CHOICES[state.choice]!.id !== "keep" ? (
166
+ <>
167
+ <span fg={theme.text}>{"esc"}</span>
168
+ <span fg={theme.muted}>{" keep mine"}</span>
169
+ </>
170
+ ) : null}
121
171
  </>
122
172
  ) : (
123
173
  <>
@@ -16,6 +16,8 @@ export const SHORTCUTS: { group: string; keys: [string, string][] }[] = [
16
16
  group: "Find and move",
17
17
  keys: [
18
18
  ["ctrl+p", "search: actions, commands, profiles, examples"],
19
+ ["pgup pgdn", "scroll the conversation (the mouse wheel works too)"],
20
+ ["↑↓ pgup pgdn", "scroll a long refined request; ⇆ picks what to do with it"],
19
21
  ["shift+tab", "move between the chat and the plan, activity, and files panels"],
20
22
  ["ctrl+o", "open or close the panels: plan, activity, files, context, tokens"],
21
23
  ["alt+1 to alt+4", "a start action, or a next step"],
@@ -17,7 +17,7 @@ import { clip } from "./text.ts"
17
17
 
18
18
  export type Line = {
19
19
  id: number
20
- kind: "sys" | "user" | "thought" | "timing" | "memory" | "tool" | "file" | "code" | "ok" | "err" | "summary" | "suitesummary" | "reply" | "card"
20
+ kind: "sys" | "user" | "thought" | "timing" | "memory" | "tool" | "file" | "code" | "ok" | "err" | "summary" | "suitesummary" | "reply" | "card" | "running" | "request" | "requestEnd"
21
21
  text: string
22
22
  summary?: RunSummary
23
23
  suite?: SuiteResult
@@ -25,6 +25,47 @@ export type Line = {
25
25
  outcome?: "done" | "failed" | "needs_input"
26
26
  /** Which side of the split view shows it: the conversation, or what the agents are doing. */
27
27
  pane?: "chat" | "activity"
28
+ /** Only the Activity panel shows it: detail the conversation leaves out at this level (/details), such as reads. */
29
+ quiet?: boolean
30
+ /** A tool call's id: its "running" line becomes the call's result line when the call finishes. */
31
+ call?: string
32
+ /** A request's first line in Activity ("request"): when it started. */
33
+ at?: number
34
+ /** A request's last line in Activity ("requestEnd"): how it ended. */
35
+ ended?: "done" | "fail" | "stopped" | "idle"
36
+ }
37
+
38
+ /** Activity lines that mark where a request starts and ends; they are not activities themselves. */
39
+ export const isMarker = (l: Line) => l.kind === "request" || l.kind === "requestEnd"
40
+
41
+ /** The activities since the last request started, markers left out: what the request did. */
42
+ export function sinceRequest(lines: Line[]): number {
43
+ let n = 0
44
+ for (let i = lines.length - 1; i >= 0; i--) {
45
+ const l = lines[i]!
46
+ if (l.kind === "request") break
47
+ if (l.pane === "activity" && !isMarker(l)) n++
48
+ }
49
+ return n
50
+ }
51
+
52
+ /** Lines kept: the oldest quiet ones go first, so a busy run never pushes the conversation out. */
53
+ export const MAX_LINES = 1500
54
+
55
+ export function trimLines(lines: Line[], max = MAX_LINES): Line[] {
56
+ if (lines.length <= max) return lines
57
+ let drop = lines.length - max
58
+ const kept = lines.filter((l) => (drop > 0 && l.quiet ? (drop--, false) : true))
59
+ return kept.length > max ? kept.slice(-max) : kept
60
+ }
61
+
62
+ /** A call has finished: its running line takes the result in place, or the result is added when there was none. */
63
+ export function settleCall(lines: Line[], call: string | undefined, line: Omit<Line, "id" | "call">, id: number): Line[] {
64
+ const at = call ? lines.findIndex((l) => l.call === call && l.kind === "running") : -1
65
+ if (at < 0) return [...lines, { ...line, id, ...(call ? { call } : {}) }]
66
+ const next = [...lines]
67
+ next[at] = { ...next[at]!, ...line, call }
68
+ return next
28
69
  }
29
70
 
30
71
  /** What one request produced: browser runs with their files, suites, and the tests the coding agent wrote. */
@@ -97,6 +138,9 @@ export function producedCard(p: Produced, tests: TestsSetting): Card | null {
97
138
  * The conversation reads top to bottom, as in Claude Code: what you asked after ">", each reply, tool call, or result
98
139
  * after "⏺", and what came of it indented under the call after "⎿". No boxes or backgrounds: marks and colour carry it.
99
140
  */
141
+ /** The lines a request shows in the conversation before it folds. */
142
+ export const USER_ROWS = 8
143
+
100
144
  export const MARK = { item: "⏺", child: "⎿", user: ">" }
101
145
 
102
146
  /** A mark in its own column and the content beside it, so wrapped lines stay under the content. */
@@ -404,9 +448,13 @@ export function TranscriptLine({ line, prev }: { line: Line; prev?: Line }) {
404
448
  if (line.kind === "summary" && line.summary) return <SummaryCard summary={line.summary} />
405
449
  if (line.kind === "suitesummary" && line.suite) return <SuiteCard suite={line.suite} />
406
450
  if (line.kind === "user") {
451
+ // A long request (a refined one, a pasted spec) folds to its first lines; it was sent in full.
452
+ const rows = line.text.split("\n")
453
+ const folded = rows.length > USER_ROWS
407
454
  return (
408
455
  <Marked mark={MARK.user} color={theme.muted} gap>
409
- <text fg={theme.muted}>{line.text}</text>
456
+ <text fg={theme.muted}>{folded ? rows.slice(0, USER_ROWS - 1).join("\n") : line.text}</text>
457
+ {folded ? <text fg={theme.faint}>{`… ${rows.length - USER_ROWS + 1} more lines, sent in full`}</text> : null}
410
458
  </Marked>
411
459
  )
412
460
  }
package/src/types.ts CHANGED
@@ -127,7 +127,9 @@ export type PlanItem = { text: string; status: "pending" | "in_progress" | "comp
127
127
  export type CodeEvent =
128
128
  | { type: "code"; kind: "info"; text: string }
129
129
  /** A finished tool call: `tool` is its name, and `file` the file it read or changed, relative to the repository. */
130
- | { type: "code"; kind: "tool"; text: string; ok: boolean; tool?: string; file?: { path: string; action: FileAction }; skill?: string }
130
+ | { type: "code"; kind: "tool"; text: string; ok: boolean; tool?: string; file?: { path: string; action: FileAction }; skill?: string; id?: string }
131
+ /** A tool call that has started and not finished yet; its `kind: "tool"` event with the same `id` follows. */
132
+ | { type: "code"; kind: "start"; text: string; tool: string; id: string }
131
133
  | { type: "code"; kind: "say"; text: string }
132
134
  | { type: "code"; kind: "test"; text: string; ok: boolean; attempt: number }
133
135
  /** The session's usage so far; `context` is the last model call's input, how full its context window is. */