@capagents/forgeqe 1.0.4 → 1.0.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.
- package/HOW_TO_USE.md +60 -3
- package/docs/user-guide.html +63 -5
- package/package.json +1 -1
- package/prompts/README.md +1 -0
- package/prompts/brain.md +4 -1
- package/prompts/refine.md +17 -0
- package/src/brain/index.ts +4 -3
- package/src/brain/prompt.ts +34 -2
- package/src/capabilities/registry.ts +12 -0
- package/src/capabilities/status.ts +4 -3
- package/src/mcp/reporttools.ts +101 -0
- package/src/mcp/tools.ts +4 -0
- package/src/refine.ts +116 -0
- package/src/tui/App.tsx +477 -47
- package/src/tui/components.tsx +78 -3
- package/src/tui/inspector.tsx +181 -39
- package/src/tui/next.ts +43 -0
- package/src/tui/palette.tsx +173 -0
- package/src/tui/refine-panel.tsx +94 -0
- package/src/tui/setup.ts +37 -0
- package/src/tui/suggest.ts +111 -0
- package/src/tui/transcript.tsx +125 -7
- package/src/usage.ts +1 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { CAPABILITIES } from "../capabilities/registry.ts"
|
|
2
|
+
import { clip } from "./text.ts"
|
|
3
|
+
import { theme } from "./theme.ts"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The command palette (ctrl+p): every action, command, profile, and example request, found by typing a few letters.
|
|
7
|
+
* Items that would start work only fill the prompt; nothing is sent without the user pressing enter there.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type PaletteGroup = "Action" | "Command" | "Profile" | "Ask"
|
|
11
|
+
|
|
12
|
+
export type PaletteRun =
|
|
13
|
+
/** Sent as if typed: a slash command. */
|
|
14
|
+
| { kind: "command"; text: string }
|
|
15
|
+
/** Put in the prompt to edit and send: an example request. */
|
|
16
|
+
| { kind: "insert"; text: string }
|
|
17
|
+
/** Something the shell does itself. */
|
|
18
|
+
| { kind: "action"; id: "refine" | "stop" | "details" | "chat" | "wide" | "panels" }
|
|
19
|
+
|
|
20
|
+
export type PaletteItem = { id: string; group: PaletteGroup; title: string; detail?: string; keys?: string; run: PaletteRun }
|
|
21
|
+
|
|
22
|
+
export const GROUP_COLOR: Record<PaletteGroup, string> = { Action: theme.warn, Command: theme.accent, Profile: theme.memory, Ask: theme.ok }
|
|
23
|
+
|
|
24
|
+
export function paletteItems(opts: { running: boolean; headed: boolean; detailsFull: boolean; profiles: string[]; active: string; narrow: boolean; view: "chat" | "details"; chatWide?: boolean }): PaletteItem[] {
|
|
25
|
+
const items: PaletteItem[] = []
|
|
26
|
+
const command = (id: string, title: string, text: string, detail?: string, keys?: string) => items.push({ id, group: "Command", title, detail, keys, run: { kind: "command", text } })
|
|
27
|
+
if (opts.running) items.push({ id: "stop", group: "Action", title: "Stop the running request", keys: "esc", run: { kind: "action", id: "stop" } })
|
|
28
|
+
items.push({ id: "refine", group: "Action", title: "Refine my request", detail: "check it against what ForgeQE knows before sending", keys: "ctrl+r", run: { kind: "action", id: "refine" } })
|
|
29
|
+
if (!opts.narrow) {
|
|
30
|
+
items.push({ id: "wide", group: "Action", title: opts.chatWide ? "Split view: chat and panels" : "Wide chat: the conversation across the screen", run: { kind: "action", id: "wide" } })
|
|
31
|
+
if (!opts.chatWide) items.push({ id: "panels", group: "Action", title: "Move to the panels: plan, activity, files", keys: "shift+tab", run: { kind: "action", id: "panels" } })
|
|
32
|
+
}
|
|
33
|
+
if (opts.narrow) items.push({ id: "view", group: "Action", title: opts.view === "chat" ? "Show the details view" : "Back to the chat", keys: "ctrl+o", run: { kind: "action", id: opts.view === "chat" ? "details" : "chat" } })
|
|
34
|
+
command("features", "What ForgeQE can do here", "/features", "and what each feature still needs")
|
|
35
|
+
command("auth", "Sign in to a system", "/auth", "Azure DevOps, GitHub, Jira, Confluence, TestRail, Zephyr, qTest, Xray")
|
|
36
|
+
command("auth-status", "What is signed in", "/auth status")
|
|
37
|
+
command("headed", opts.headed ? "Hide the browser window" : "Show the browser window", `/headed ${opts.headed ? "off" : "on"}`)
|
|
38
|
+
command("details", opts.detailsFull ? "Show progress only" : "Show everything the agents do", `/details ${opts.detailsFull ? "off" : "on"}`)
|
|
39
|
+
command("skills", "Skills each agent can use", "/skills")
|
|
40
|
+
command("logs", "Save this session's log to share", "/logs")
|
|
41
|
+
command("clear", "Clear the chat", "/clear")
|
|
42
|
+
command("help", "Help", "/help")
|
|
43
|
+
command("exit", "Quit ForgeQE", "/exit", undefined, "ctrl+c")
|
|
44
|
+
for (const p of opts.profiles) if (p !== opts.active) items.push({ id: `profile:${p}`, group: "Profile", title: `Use the ${p} profile`, keys: "tab", run: { kind: "command", text: `/profile ${p}` } })
|
|
45
|
+
for (const c of CAPABILITIES) for (const e of c.examples) items.push({ id: `ask:${c.id}:${e}`, group: "Ask", title: e, detail: c.title, run: { kind: "insert", text: e } })
|
|
46
|
+
return items
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How well `query` matches `text`, case ignored; null when it does not. In order of strength: the text contains the
|
|
51
|
+
* query (best at the start of a word), every typed word starts a word of the text ("run sp" finds "Run … sprint"),
|
|
52
|
+
* or a short query is the text's initials ("rr" finds "Refine my request"). Letters merely scattered through the text
|
|
53
|
+
* do not match: they find everything and help nobody.
|
|
54
|
+
*/
|
|
55
|
+
export function fuzzyScore(query: string, text: string): number | null {
|
|
56
|
+
const q = query.trim().toLowerCase()
|
|
57
|
+
if (!q) return 0
|
|
58
|
+
const t = text.toLowerCase()
|
|
59
|
+
const words = t.split(/[^a-z0-9]+/).filter(Boolean)
|
|
60
|
+
const at = t.indexOf(q)
|
|
61
|
+
if (at >= 0) return 100 + (at === 0 || /[^a-z0-9]/.test(t[at - 1]!) ? 20 : 0) - Math.min(20, at / 4)
|
|
62
|
+
const typed = q.split(/\s+/).filter(Boolean)
|
|
63
|
+
if (typed.length > 1 && typed.every((w) => words.some((x) => x.startsWith(w)))) return 70 - typed.length
|
|
64
|
+
if (typed.length === 1 && q.length >= 2 && q.length <= 5) {
|
|
65
|
+
if (words.map((w) => w[0]).join("").includes(q)) return 40
|
|
66
|
+
}
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The items that match, best first; with no query, in their own order. */
|
|
71
|
+
export function searchPalette(query: string, items: PaletteItem[], limit = 50): PaletteItem[] {
|
|
72
|
+
if (!query.trim()) return items.slice(0, limit)
|
|
73
|
+
return items
|
|
74
|
+
.map((item, order) => {
|
|
75
|
+
const own = fuzzyScore(query, item.title)
|
|
76
|
+
// Also what a command is typed as ("/auth", "/headed"), so people who know the command find it by its name.
|
|
77
|
+
const typedAs = item.run.kind === "command" ? item.run.text : ""
|
|
78
|
+
const more = fuzzyScore(query, `${item.title} ${item.detail || ""} ${typedAs} ${item.group}`)
|
|
79
|
+
const best = own === null ? (more === null ? null : more - 2) : Math.max(own + 2, more ?? own)
|
|
80
|
+
return { item, order, score: best }
|
|
81
|
+
})
|
|
82
|
+
.filter((x): x is { item: PaletteItem; order: number; score: number } => x.score !== null)
|
|
83
|
+
.sort((a, b) => b.score - a.score || a.order - b.order)
|
|
84
|
+
.slice(0, limit)
|
|
85
|
+
.map((x) => x.item)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function Palette(props: { width: number; height: number; query: string; items: PaletteItem[]; selected: number; onPick: (item: PaletteItem) => void; onHover: (index: number) => void }) {
|
|
89
|
+
const boxWidth = Math.min(100, props.width - 8)
|
|
90
|
+
const rows = Math.max(4, Math.min(16, props.height - 12))
|
|
91
|
+
// Keep the selection in view: the window of rows slides with it.
|
|
92
|
+
const first = Math.max(0, Math.min(props.selected - Math.floor(rows / 2), props.items.length - rows))
|
|
93
|
+
const shown = props.items.slice(first, first + rows)
|
|
94
|
+
const room = boxWidth - 6
|
|
95
|
+
return (
|
|
96
|
+
<box
|
|
97
|
+
position="absolute"
|
|
98
|
+
zIndex={100}
|
|
99
|
+
border
|
|
100
|
+
borderStyle="rounded"
|
|
101
|
+
borderColor={theme.accent}
|
|
102
|
+
title=" Command palette "
|
|
103
|
+
titleColor={theme.accent}
|
|
104
|
+
bottomTitle=" ↑↓ select · enter choose · esc close "
|
|
105
|
+
bottomTitleAlignment="right"
|
|
106
|
+
style={{ top: 2, left: Math.floor((props.width - boxWidth) / 2), width: boxWidth, flexDirection: "column", backgroundColor: theme.panel, paddingLeft: 1, paddingRight: 1 }}
|
|
107
|
+
>
|
|
108
|
+
<text>
|
|
109
|
+
<span fg={theme.accent}>{"› "}</span>
|
|
110
|
+
{props.query ? <span fg={theme.text}>{props.query}</span> : <span fg={theme.faint}>{"Type to find an action, a command, a profile, or something to ask"}</span>}
|
|
111
|
+
<span fg={theme.accent}>{"▏"}</span>
|
|
112
|
+
</text>
|
|
113
|
+
<text fg={theme.border}>{"─".repeat(Math.max(4, boxWidth - 4))}</text>
|
|
114
|
+
{shown.length ? (
|
|
115
|
+
shown.map((item, i) => {
|
|
116
|
+
const index = first + i
|
|
117
|
+
const active = index === props.selected
|
|
118
|
+
const keys = item.keys ? ` ${item.keys}` : ""
|
|
119
|
+
const titleRoom = Math.max(10, room - 10 - keys.length - (item.detail ? Math.min(34, item.detail.length) + 2 : 0))
|
|
120
|
+
return (
|
|
121
|
+
<box
|
|
122
|
+
key={item.id}
|
|
123
|
+
onMouseDown={() => props.onPick(item)}
|
|
124
|
+
onMouseOver={() => props.onHover(index)}
|
|
125
|
+
style={{ flexDirection: "row", justifyContent: "space-between", backgroundColor: active ? theme.button : theme.panel, flexShrink: 0 }}
|
|
126
|
+
>
|
|
127
|
+
<text>
|
|
128
|
+
<span fg={active ? theme.accent : theme.panel}>{"▌"}</span>
|
|
129
|
+
<span fg={GROUP_COLOR[item.group]}>{item.group.padEnd(8)}</span>
|
|
130
|
+
<span fg={active ? theme.text : theme.muted}>{clip(item.title, titleRoom)}</span>
|
|
131
|
+
{item.detail ? <span fg={theme.faint}>{` ${clip(item.detail, 34)}`}</span> : null}
|
|
132
|
+
</text>
|
|
133
|
+
<text fg={theme.faint}>{keys}</text>
|
|
134
|
+
</box>
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
) : (
|
|
138
|
+
<text fg={theme.faint}>{" Nothing matches. Enter puts what you typed in the prompt; esc closes."}</text>
|
|
139
|
+
)}
|
|
140
|
+
{props.items.length > rows ? <text fg={theme.faint}>{` ${props.items.length} matches`}</text> : null}
|
|
141
|
+
</box>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The suggestions above the prompt while "/" or "@" is typed: same rows as the palette, so the two read as one. */
|
|
146
|
+
export function SuggestList(props: { title: string; width: number; items: { label: string; detail?: string; color: string }[]; selected: number; onPick: (i: number) => void; onHover: (i: number) => void }) {
|
|
147
|
+
const room = props.width - 6
|
|
148
|
+
return (
|
|
149
|
+
<box
|
|
150
|
+
border
|
|
151
|
+
borderStyle="rounded"
|
|
152
|
+
borderColor={theme.border}
|
|
153
|
+
title={` ${props.title} `}
|
|
154
|
+
titleColor={theme.accent}
|
|
155
|
+
bottomTitle=" tab complete · ↑↓ · esc "
|
|
156
|
+
bottomTitleAlignment="right"
|
|
157
|
+
style={{ flexDirection: "column", width: props.width, paddingLeft: 1, paddingRight: 1, backgroundColor: theme.panel, flexShrink: 0 }}
|
|
158
|
+
>
|
|
159
|
+
{props.items.map((item, i) => {
|
|
160
|
+
const active = i === props.selected
|
|
161
|
+
return (
|
|
162
|
+
<box key={`${item.label}-${i}`} onMouseDown={() => props.onPick(i)} onMouseOver={() => props.onHover(i)} style={{ flexDirection: "row", backgroundColor: active ? theme.button : theme.panel, flexShrink: 0 }}>
|
|
163
|
+
<text>
|
|
164
|
+
<span fg={active ? theme.accent : theme.panel}>{"▌"}</span>
|
|
165
|
+
<span fg={item.color}>{clip(item.label, Math.max(10, Math.floor(room / 2)))}</span>
|
|
166
|
+
{item.detail ? <span fg={theme.faint}>{` ${clip(item.detail, Math.max(8, room - item.label.length - 4))}`}</span> : null}
|
|
167
|
+
</text>
|
|
168
|
+
</box>
|
|
169
|
+
)
|
|
170
|
+
})}
|
|
171
|
+
</box>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Refined } from "../refine.ts"
|
|
2
|
+
import { Spinner } from "./components.tsx"
|
|
3
|
+
import { theme } from "./theme.ts"
|
|
4
|
+
|
|
5
|
+
/** Refine's panel, in the prompt's place: working, the result with what was checked, or why it could not refine. */
|
|
6
|
+
export type RefineState =
|
|
7
|
+
| { phase: "working"; original: string }
|
|
8
|
+
| { phase: "done"; original: string; result: Refined; choice: number }
|
|
9
|
+
| { phase: "error"; original: string; message: string }
|
|
10
|
+
|
|
11
|
+
export const REFINE_CHOICES = [
|
|
12
|
+
{ id: "use", label: "Use refined", key: "enter" },
|
|
13
|
+
{ id: "edit", label: "Edit", key: "e" },
|
|
14
|
+
{ id: "keep", label: "Keep mine", key: "esc" },
|
|
15
|
+
] as const
|
|
16
|
+
|
|
17
|
+
export type RefineChoice = (typeof REFINE_CHOICES)[number]["id"]
|
|
18
|
+
|
|
19
|
+
const COLOR = theme.memory
|
|
20
|
+
|
|
21
|
+
export function RefinePanel(props: { state: RefineState; width: number; onChoose: (id: RefineChoice) => void; onHover: (i: number) => void }) {
|
|
22
|
+
const { state, width } = props
|
|
23
|
+
const title = state.phase === "working" ? " ✦ Refining " : state.phase === "error" ? " ✦ Could not refine " : " ✦ Refined request "
|
|
24
|
+
const section = (label: string, color: string, mark: string, items: string[]) =>
|
|
25
|
+
items.length ? (
|
|
26
|
+
<box style={{ flexDirection: "column", marginTop: 1 }}>
|
|
27
|
+
<text fg={theme.muted}>{label}</text>
|
|
28
|
+
{items.map((it, i) => (
|
|
29
|
+
<box key={i} style={{ flexDirection: "row" }}>
|
|
30
|
+
<text fg={color} style={{ width: 2, flexShrink: 0 }}>
|
|
31
|
+
{mark}
|
|
32
|
+
</text>
|
|
33
|
+
<text fg={theme.text} style={{ flexGrow: 1, flexShrink: 1 }}>
|
|
34
|
+
{it}
|
|
35
|
+
</text>
|
|
36
|
+
</box>
|
|
37
|
+
))}
|
|
38
|
+
</box>
|
|
39
|
+
) : null
|
|
40
|
+
return (
|
|
41
|
+
<box style={{ flexDirection: "column", width, flexShrink: 0 }}>
|
|
42
|
+
<box border borderStyle="rounded" borderColor={COLOR} title={title} titleColor={COLOR} style={{ flexDirection: "column", paddingLeft: 1, paddingRight: 1, backgroundColor: theme.panel }}>
|
|
43
|
+
{state.phase === "working" ? (
|
|
44
|
+
<box style={{ flexDirection: "row" }}>
|
|
45
|
+
<Spinner kind="dots" color={COLOR} />
|
|
46
|
+
<text fg={theme.muted}>{" Checking your request against this setup: capabilities, systems, project, recent runs…"}</text>
|
|
47
|
+
</box>
|
|
48
|
+
) : state.phase === "error" ? (
|
|
49
|
+
<text fg={theme.err}>{state.message}</text>
|
|
50
|
+
) : (
|
|
51
|
+
<>
|
|
52
|
+
<text fg={theme.text}>{state.result.refined}</text>
|
|
53
|
+
{section("Checked", theme.ok, "✓", state.result.grounded.filter((g) => g.checked).map((g) => g.fact))}
|
|
54
|
+
{section("Assumed", theme.warn, "~", [...state.result.grounded.filter((g) => !g.checked).map((g) => g.fact), ...state.result.assumed])}
|
|
55
|
+
{section("Missing — answer these in your request", theme.err, "?", state.result.missing)}
|
|
56
|
+
</>
|
|
57
|
+
)}
|
|
58
|
+
</box>
|
|
59
|
+
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, marginTop: 0 }}>
|
|
60
|
+
{state.phase === "done" ? (
|
|
61
|
+
<box style={{ flexDirection: "row" }}>
|
|
62
|
+
{REFINE_CHOICES.map((c, i) => {
|
|
63
|
+
const active = i === state.choice
|
|
64
|
+
return (
|
|
65
|
+
<box key={c.id} onMouseDown={() => props.onChoose(c.id)} onMouseOver={() => props.onHover(i)} style={{ paddingLeft: 1, paddingRight: 1, marginRight: 1, backgroundColor: active ? COLOR : theme.button }}>
|
|
66
|
+
<text fg={active ? theme.onButton : theme.muted}>{c.label}</text>
|
|
67
|
+
</box>
|
|
68
|
+
)
|
|
69
|
+
})}
|
|
70
|
+
</box>
|
|
71
|
+
) : (
|
|
72
|
+
<text fg={theme.faint}>{state.phase === "working" ? "" : "Your request is back in the prompt."}</text>
|
|
73
|
+
)}
|
|
74
|
+
<text>
|
|
75
|
+
{state.phase === "done" ? (
|
|
76
|
+
<>
|
|
77
|
+
<span fg={theme.text}>{"enter"}</span>
|
|
78
|
+
<span fg={theme.muted}>{" use "}</span>
|
|
79
|
+
<span fg={theme.text}>{"e"}</span>
|
|
80
|
+
<span fg={theme.muted}>{" edit "}</span>
|
|
81
|
+
<span fg={theme.text}>{"esc"}</span>
|
|
82
|
+
<span fg={theme.muted}>{" keep mine"}</span>
|
|
83
|
+
</>
|
|
84
|
+
) : (
|
|
85
|
+
<>
|
|
86
|
+
<span fg={theme.text}>{"esc"}</span>
|
|
87
|
+
<span fg={theme.muted}>{state.phase === "working" ? " cancel" : " close"}</span>
|
|
88
|
+
</>
|
|
89
|
+
)}
|
|
90
|
+
</text>
|
|
91
|
+
</box>
|
|
92
|
+
</box>
|
|
93
|
+
)
|
|
94
|
+
}
|
package/src/tui/setup.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AppConfig } from "../config.ts"
|
|
2
|
+
import { MACHINE, requirementGap, type Probes } from "../capabilities/status.ts"
|
|
3
|
+
import type { Requirement } from "../capabilities/registry.ts"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The home screen's setup checklist: the few things most features rest on, each checked the way the capability
|
|
7
|
+
* registry checks a requirement, so the checklist and /features never disagree.
|
|
8
|
+
*/
|
|
9
|
+
export type SetupItem = {
|
|
10
|
+
id: "model" | "agent" | "system" | "domain" | "notify"
|
|
11
|
+
label: string
|
|
12
|
+
done: boolean
|
|
13
|
+
/** What is missing, and how to set it up: shown when the item is chosen. */
|
|
14
|
+
fix?: string
|
|
15
|
+
/** A command that sets it up, run when the item is chosen. */
|
|
16
|
+
command?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CHECKS: { id: SetupItem["id"]; label: string; requirement: Requirement; command?: string }[] = [
|
|
20
|
+
{ id: "model", label: "Model", requirement: { kind: "live_model" } },
|
|
21
|
+
{ id: "agent", label: "Coding agent", requirement: { kind: "coding_agent" } },
|
|
22
|
+
{ id: "system", label: "Work items", requirement: { kind: "system", anyOf: ["azure_devops", "jira", "testrail", "zephyr", "qtest", "xray"] }, command: "/auth" },
|
|
23
|
+
{ id: "domain", label: "Domain", requirement: { kind: "domain" } },
|
|
24
|
+
{ id: "notify", label: "Notifications", requirement: { kind: "notify_channels" } },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
export function setupChecklist(cfg: AppConfig, probes: Probes = MACHINE): SetupItem[] {
|
|
28
|
+
return CHECKS.map((c) => {
|
|
29
|
+
const gap = requirementGap(c.requirement, cfg, probes)
|
|
30
|
+
return {
|
|
31
|
+
id: c.id,
|
|
32
|
+
label: c.label,
|
|
33
|
+
done: !gap,
|
|
34
|
+
...(gap ? { fix: `${c.label}: ForgeQE needs ${gap.missing}. To set it up, ${gap.fix}.`, ...(c.command ? { command: c.command } : {}) } : {}),
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import { adoSettings } from "../cases/ado.ts"
|
|
6
|
+
import { http } from "../cases/http.ts"
|
|
7
|
+
import type { AppConfig } from "../config.ts"
|
|
8
|
+
import type { PaletteItem } from "./palette.tsx"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Suggestions while typing: "/" completes a command, "@" a reference to something real (a recent run, a file in the
|
|
12
|
+
* repository, an Azure DevOps work item looked up by its id). Nothing is suggested that does not exist.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type Suggestion = { kind: "command" | "run" | "file" | "item"; label: string; detail?: string; insert: string }
|
|
16
|
+
|
|
17
|
+
/** What is being completed: a command at the start of the prompt, or the "@" word being typed at its end. */
|
|
18
|
+
export type Trigger = { kind: "/" | "@"; token: string; start: number }
|
|
19
|
+
|
|
20
|
+
export function triggerAt(text: string): Trigger | null {
|
|
21
|
+
if (/^\/\S*$/.test(text)) return { kind: "/", token: text.slice(1), start: 0 }
|
|
22
|
+
const m = /(^|\s)@(\S*)$/.exec(text)
|
|
23
|
+
return m ? { kind: "@", token: m[2]!, start: m.index + m[1]!.length } : null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The prompt with the trigger's word replaced by the completion, ready for more typing. */
|
|
27
|
+
export function complete(text: string, t: Trigger, insert: string): string {
|
|
28
|
+
return `${text.slice(0, t.start)}${insert} `
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function commandSuggestions(items: PaletteItem[], token: string): Suggestion[] {
|
|
32
|
+
const seen = new Set<string>()
|
|
33
|
+
const out: Suggestion[] = []
|
|
34
|
+
for (const item of items) {
|
|
35
|
+
if (item.run.kind !== "command" || seen.has(item.run.text)) continue
|
|
36
|
+
seen.add(item.run.text)
|
|
37
|
+
out.push({ kind: "command", label: item.run.text, detail: item.title, insert: item.run.text })
|
|
38
|
+
}
|
|
39
|
+
const t = token.toLowerCase()
|
|
40
|
+
return out.filter((s) => s.label.slice(1).toLowerCase().startsWith(t)).concat(out.filter((s) => !s.label.slice(1).toLowerCase().startsWith(t) && s.label.toLowerCase().includes(t)))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type RecentRun = { id: string; dir: string; goal: string }
|
|
44
|
+
|
|
45
|
+
/** The newest run folders (named by their start time) with their goals. */
|
|
46
|
+
export function recentRuns(outDir: string, limit = 8): RecentRun[] {
|
|
47
|
+
if (!existsSync(outDir)) return []
|
|
48
|
+
return readdirSync(outDir)
|
|
49
|
+
.filter((d) => /^\d{8}-\d{6}(-\d+)?$/.test(d))
|
|
50
|
+
.sort()
|
|
51
|
+
.reverse()
|
|
52
|
+
.slice(0, limit)
|
|
53
|
+
.map((id) => {
|
|
54
|
+
let goal = ""
|
|
55
|
+
try {
|
|
56
|
+
goal = String(JSON.parse(readFileSync(path.join(outDir, id, "run.json"), "utf8")).goal || "")
|
|
57
|
+
} catch {}
|
|
58
|
+
return { id, dir: path.join(outDir, id), goal }
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const filesCache = new Map<string, string[]>()
|
|
63
|
+
|
|
64
|
+
/** The repository's tracked files (git ls-files), once per folder; none outside a git repository. */
|
|
65
|
+
export function repoFiles(cwd: string): string[] {
|
|
66
|
+
const known = filesCache.get(cwd)
|
|
67
|
+
if (known) return known
|
|
68
|
+
const r = spawnSync("git", ["ls-files"], { cwd, encoding: "utf8", maxBuffer: 20_000_000 })
|
|
69
|
+
const files = r.status === 0 ? r.stdout.split("\n").filter(Boolean).slice(0, 20_000) : []
|
|
70
|
+
filesCache.set(cwd, files)
|
|
71
|
+
return files
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type WorkItem = { id: number; title: string; type: string; project: string }
|
|
75
|
+
|
|
76
|
+
/** What `@` can refer to for the word typed after it. */
|
|
77
|
+
export function referenceSuggestions(token: string, src: { runs: RecentRun[]; files: string[]; item?: WorkItem | null; cwd: string }): Suggestion[] {
|
|
78
|
+
const t = token.toLowerCase()
|
|
79
|
+
const out: Suggestion[] = []
|
|
80
|
+
if (/^\d+$/.test(token)) {
|
|
81
|
+
if (src.item) out.push({ kind: "item", label: `${src.item.type} ${src.item.id}`, detail: `${src.item.title} · ${src.item.project}`, insert: `Azure DevOps ${src.item.type.toLowerCase()} ${src.item.id} ("${src.item.title}")` })
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
84
|
+
if (!t || "run".startsWith(t) || t.startsWith("run")) {
|
|
85
|
+
const runs = src.runs.filter((r) => !t.startsWith("run") || t === "run" || r.id.includes(t.slice(3)))
|
|
86
|
+
for (const [i, r] of runs.entries()) out.push({ kind: "run", label: i === 0 ? "last run" : `run ${r.id}`, detail: r.goal || r.id, insert: `the run in ${path.relative(src.cwd, r.dir) || r.dir}` })
|
|
87
|
+
}
|
|
88
|
+
if (t.length >= 2) {
|
|
89
|
+
const byName = src.files.filter((f) => path.basename(f).toLowerCase().includes(t))
|
|
90
|
+
const byPath = src.files.filter((f) => !path.basename(f).toLowerCase().includes(t) && f.toLowerCase().includes(t))
|
|
91
|
+
for (const f of [...byName, ...byPath].slice(0, 8)) out.push({ kind: "file", label: path.basename(f), detail: path.dirname(f) === "." ? "" : path.dirname(f), insert: f })
|
|
92
|
+
}
|
|
93
|
+
return out.slice(0, 10)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const itemCache = new Map<number, WorkItem | null>()
|
|
97
|
+
|
|
98
|
+
/** An Azure DevOps work item's title, type, and project by its id; null when it does not exist or cannot be read. */
|
|
99
|
+
export async function lookupWorkItem(cfg: AppConfig, id: number): Promise<WorkItem | null> {
|
|
100
|
+
if (itemCache.has(id)) return itemCache.get(id)!
|
|
101
|
+
let item: WorkItem | null = null
|
|
102
|
+
try {
|
|
103
|
+
const { auth, org } = adoSettings(cfg.sources)
|
|
104
|
+
const { data } = await http<{ id: number; fields: Record<string, unknown> }>(`${org}/_apis/wit/workitems/${id}?fields=System.Title,System.WorkItemType,System.TeamProject&api-version=7.1`, { auth })
|
|
105
|
+
item = { id: data.id, title: String(data.fields["System.Title"] || ""), type: String(data.fields["System.WorkItemType"] || "Work item"), project: String(data.fields["System.TeamProject"] || "") }
|
|
106
|
+
} catch {
|
|
107
|
+
item = null
|
|
108
|
+
}
|
|
109
|
+
itemCache.set(id, item)
|
|
110
|
+
return item
|
|
111
|
+
}
|
package/src/tui/transcript.tsx
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import os from "node:os"
|
|
1
3
|
import path from "node:path"
|
|
2
4
|
import { SyntaxStyle } from "@opentui/core"
|
|
3
5
|
|
|
@@ -29,9 +31,11 @@ export type Produced = {
|
|
|
29
31
|
runs: { runDir: string; status: RunStatus; files: string[] }[]
|
|
30
32
|
suites: { html: string; junit: string }[]
|
|
31
33
|
tests: { runDir: string; result?: CodeResult; error?: string }[]
|
|
34
|
+
/** Reports write_report saved: the page to open. */
|
|
35
|
+
reports: { title: string; html: string }[]
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
export const emptyProduced = (): Produced => ({ runs: [], suites: [], tests: [] })
|
|
38
|
+
export const emptyProduced = (): Produced => ({ runs: [], suites: [], tests: [], reports: [] })
|
|
35
39
|
|
|
36
40
|
/** Whether passed runs become tests this session, and why not when they don't. */
|
|
37
41
|
export type TestsSetting = { on: boolean; why: string }
|
|
@@ -147,7 +151,7 @@ export function SummaryCard({ summary }: { summary: RunSummary }) {
|
|
|
147
151
|
{summary.savedDir ? (
|
|
148
152
|
<text>
|
|
149
153
|
<span fg={theme.faint}>{"Saved "}</span>
|
|
150
|
-
<
|
|
154
|
+
<Linked text={`${summary.savedDir}/ `} color={theme.muted} />
|
|
151
155
|
<span fg={theme.faint}>{summary.savedFiles.join(" · ")}</span>
|
|
152
156
|
</text>
|
|
153
157
|
) : null}
|
|
@@ -155,12 +159,54 @@ export function SummaryCard({ summary }: { summary: RunSummary }) {
|
|
|
155
159
|
)
|
|
156
160
|
}
|
|
157
161
|
|
|
162
|
+
/** A web address, or a path with at least one folder: what Linked turns into a link when it exists. */
|
|
163
|
+
const LINKABLE = /(https?:\/\/[^\s)"'<>]+|(?:~|\.{1,2})?\/?[\w.@-]+(?:\/[\w.@-]+)+\/?)/g
|
|
164
|
+
|
|
165
|
+
/** The target of a link: the address itself, or the file's absolute path; null for a path that does not exist. */
|
|
166
|
+
export function linkTarget(token: string, cwd = process.cwd()): string | null {
|
|
167
|
+
if (/^https?:\/\//.test(token)) return token
|
|
168
|
+
const file = token.startsWith("~/") ? path.join(os.homedir(), token.slice(2)) : path.resolve(cwd, token)
|
|
169
|
+
return existsSync(file) ? `file://${file}` : null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Text whose web addresses and existing file paths can be opened from the terminal (OSC 8 links: cmd/ctrl+click in
|
|
174
|
+
* VS Code and most terminals). A path that does not exist stays plain text, so no link leads nowhere.
|
|
175
|
+
*/
|
|
176
|
+
export function Linked({ text, color }: { text: string; color: string }) {
|
|
177
|
+
const parts: { text: string; href?: string }[] = []
|
|
178
|
+
let at = 0
|
|
179
|
+
for (const m of text.matchAll(LINKABLE)) {
|
|
180
|
+
const href = linkTarget(m[0])
|
|
181
|
+
if (!href) continue
|
|
182
|
+
if (m.index! > at) parts.push({ text: text.slice(at, m.index) })
|
|
183
|
+
parts.push({ text: m[0], href })
|
|
184
|
+
at = m.index! + m[0].length
|
|
185
|
+
}
|
|
186
|
+
if (at < text.length) parts.push({ text: text.slice(at) })
|
|
187
|
+
return (
|
|
188
|
+
<>
|
|
189
|
+
{parts.map((p, i) =>
|
|
190
|
+
p.href ? (
|
|
191
|
+
<a key={i} href={p.href} fg={theme.accent}>
|
|
192
|
+
{p.text}
|
|
193
|
+
</a>
|
|
194
|
+
) : (
|
|
195
|
+
<span key={i} fg={color}>
|
|
196
|
+
{p.text}
|
|
197
|
+
</span>
|
|
198
|
+
),
|
|
199
|
+
)}
|
|
200
|
+
</>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
158
204
|
export function Row({ label, value, color = theme.muted }: { label: string; value: string; color?: string }) {
|
|
159
205
|
if (!value) return null
|
|
160
206
|
return (
|
|
161
207
|
<text>
|
|
162
208
|
<span fg={theme.faint}>{label.padEnd(10)}</span>
|
|
163
|
-
<
|
|
209
|
+
<Linked text={value} color={color} />
|
|
164
210
|
</text>
|
|
165
211
|
)
|
|
166
212
|
}
|
|
@@ -257,9 +303,7 @@ export function ResultCard({ card }: { card: Card }) {
|
|
|
257
303
|
</box>
|
|
258
304
|
{card.body.length ? <box style={{ height: 1 }} /> : null}
|
|
259
305
|
{card.body.map((l, i) => (
|
|
260
|
-
<text key={i}
|
|
261
|
-
{l || " "}
|
|
262
|
-
</text>
|
|
306
|
+
<text key={i}>{l ? <Linked text={l} color={lineColor(l)} /> : " "}</text>
|
|
263
307
|
))}
|
|
264
308
|
</box>
|
|
265
309
|
)
|
|
@@ -283,6 +327,80 @@ function replyStyle(): SyntaxStyle {
|
|
|
283
327
|
return style
|
|
284
328
|
}
|
|
285
329
|
|
|
330
|
+
/** A reply cut into what reads better laid out than wrapped: headings, list items with their depth, and the rest. */
|
|
331
|
+
export type ReplyBlock =
|
|
332
|
+
| { kind: "heading"; text: string }
|
|
333
|
+
| { kind: "item"; depth: number; mark: string; text: string }
|
|
334
|
+
| { kind: "markdown"; text: string }
|
|
335
|
+
|
|
336
|
+
const ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/
|
|
337
|
+
|
|
338
|
+
export function replyBlocks(md: string): ReplyBlock[] {
|
|
339
|
+
const blocks: ReplyBlock[] = []
|
|
340
|
+
let rest: string[] = []
|
|
341
|
+
let fence = false
|
|
342
|
+
const flush = () => {
|
|
343
|
+
const text = rest.join("\n").trim()
|
|
344
|
+
if (text) blocks.push({ kind: "markdown", text })
|
|
345
|
+
rest = []
|
|
346
|
+
}
|
|
347
|
+
for (const line of md.replace(/\r/g, "").split("\n")) {
|
|
348
|
+
if (/^\s*(```|~~~)/.test(line)) fence = !fence
|
|
349
|
+
const last = blocks.at(-1)
|
|
350
|
+
if (fence || /^\s*(```|~~~)/.test(line) || /^\s*\|/.test(line)) {
|
|
351
|
+
rest.push(line)
|
|
352
|
+
continue
|
|
353
|
+
}
|
|
354
|
+
const heading = /^#{1,6}\s+(.*)$/.exec(line)
|
|
355
|
+
const item = ITEM.exec(line)
|
|
356
|
+
if (heading) {
|
|
357
|
+
flush()
|
|
358
|
+
blocks.push({ kind: "heading", text: heading[1]!.replace(/\*\*/g, "").trim() })
|
|
359
|
+
} else if (item) {
|
|
360
|
+
flush()
|
|
361
|
+
const depth = Math.min(3, Math.floor(item[1]!.replace(/\t/g, " ").length / 2))
|
|
362
|
+
const ordered = /\d/.test(item[2]!)
|
|
363
|
+
blocks.push({ kind: "item", depth, mark: ordered ? item[2]!.replace(")", ".") : depth ? "◦" : "•", text: item[3]! })
|
|
364
|
+
} else if (line.trim() && /^\s+\S/.test(line) && last?.kind === "item" && !rest.length) {
|
|
365
|
+
// A list item's continuation line: it belongs to the item, under its text.
|
|
366
|
+
last.text += ` ${line.trim()}`
|
|
367
|
+
} else if (!line.trim()) {
|
|
368
|
+
rest.push(line)
|
|
369
|
+
} else rest.push(line)
|
|
370
|
+
}
|
|
371
|
+
flush()
|
|
372
|
+
return blocks
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** The agent's reply: headings with room above, list items with their marks in a column so wrapped lines hang under the text. */
|
|
376
|
+
function ReplyBody({ text }: { text: string }) {
|
|
377
|
+
const blocks = replyBlocks(text)
|
|
378
|
+
return (
|
|
379
|
+
<box style={{ flexDirection: "column", flexShrink: 0 }}>
|
|
380
|
+
{blocks.map((b, i) =>
|
|
381
|
+
b.kind === "heading" ? (
|
|
382
|
+
<text key={i} fg={theme.accent} style={{ marginTop: i ? 1 : 0, flexShrink: 0 }}>
|
|
383
|
+
<strong>{b.text}</strong>
|
|
384
|
+
</text>
|
|
385
|
+
) : b.kind === "item" ? (
|
|
386
|
+
<box key={i} style={{ flexDirection: "row", flexShrink: 0, paddingLeft: b.depth * 2, marginTop: blocks[i - 1]?.kind === "markdown" ? 1 : 0 }}>
|
|
387
|
+
<text fg={theme.accent} style={{ width: b.mark.length + 1, flexShrink: 0 }}>
|
|
388
|
+
{b.mark}
|
|
389
|
+
</text>
|
|
390
|
+
<box style={{ flexGrow: 1, flexShrink: 1 }}>
|
|
391
|
+
<markdown content={b.text} syntaxStyle={replyStyle()} fg={theme.text} conceal />
|
|
392
|
+
</box>
|
|
393
|
+
</box>
|
|
394
|
+
) : (
|
|
395
|
+
<box key={i} style={{ marginTop: i ? 1 : 0, flexShrink: 0 }}>
|
|
396
|
+
<markdown content={b.text} syntaxStyle={replyStyle()} fg={theme.text} conceal tableOptions={{ widthMode: "content", wrapMode: "word" }} />
|
|
397
|
+
</box>
|
|
398
|
+
),
|
|
399
|
+
)}
|
|
400
|
+
</box>
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
|
|
286
404
|
export function TranscriptLine({ line }: { line: Line }) {
|
|
287
405
|
if (line.kind === "card" && line.card) return <ResultCard card={line.card} />
|
|
288
406
|
if (line.kind === "summary" && line.summary) return <SummaryCard summary={line.summary} />
|
|
@@ -296,7 +414,7 @@ export function TranscriptLine({ line }: { line: Line }) {
|
|
|
296
414
|
customBorderChars={BAR}
|
|
297
415
|
style={{ backgroundColor: theme.panel, paddingLeft: 2, paddingRight: 2, paddingTop: 1, paddingBottom: 1, marginTop: 1, marginBottom: 1, flexShrink: 0 }}
|
|
298
416
|
>
|
|
299
|
-
<
|
|
417
|
+
<ReplyBody text={line.text} />
|
|
300
418
|
</box>
|
|
301
419
|
)
|
|
302
420
|
}
|
package/src/usage.ts
CHANGED
|
@@ -98,7 +98,7 @@ export function usageLine(usage: Usage, cost?: Cost | null): string {
|
|
|
98
98
|
// The meter: every model session reports here, by role
|
|
99
99
|
// ---------------------------------------------------------------------------------------------
|
|
100
100
|
|
|
101
|
-
export type Role = "ForgeQE's agent" | "browser agent" | "Scribe" | "reviewer" | "judge"
|
|
101
|
+
export type Role = "ForgeQE's agent" | "browser agent" | "Scribe" | "reviewer" | "judge" | "refiner"
|
|
102
102
|
|
|
103
103
|
type Entry = { usage: Usage; cost?: Cost; priced: boolean }
|
|
104
104
|
|