@capagents/forgeqe 1.0.4 → 1.0.5

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.
@@ -50,6 +50,12 @@ export type PromptProps = {
50
50
  masked?: number
51
51
  /** Beside a session panel that names the model and profile, and a status bar: no bottom row. */
52
52
  compact?: boolean
53
+ /** Text the box starts with when `inputKey` changes (a refined request, an example from the palette). */
54
+ initialValue?: string
55
+ /** False while an overlay (the palette, a suggestion list) takes the keyboard. Default true. */
56
+ focused?: boolean
57
+ /** Called with the text as it changes, for actions on what is being typed (Refine). */
58
+ onText?: (text: string) => void
53
59
  }
54
60
 
55
61
  /** Enter sends; Shift+Enter, Alt+Enter or Ctrl+J start a new line. */
@@ -66,7 +72,12 @@ export function Prompt(props: PromptProps) {
66
72
  const ref = useRef<TextareaRenderable>(null)
67
73
  const [rows, setRows] = useState(props.minRows)
68
74
  useEffect(() => setRows(props.minRows), [props.inputKey, props.minRows])
75
+ // Inserted text (a completion, an example, a refined request) is continued from its end.
76
+ useEffect(() => {
77
+ if (props.initialValue) ref.current?.gotoBufferEnd()
78
+ }, [props.inputKey])
69
79
  const fit = () => {
80
+ props.onText?.(ref.current?.plainText ?? "")
70
81
  const lines = ref.current?.virtualLineCount ?? 1
71
82
  setRows(Math.min(props.maxRows, Math.max(props.minRows, lines)))
72
83
  }
@@ -95,7 +106,8 @@ export function Prompt(props: PromptProps) {
95
106
  key={props.inputKey}
96
107
  ref={ref}
97
108
  placeholder={props.placeholder}
98
- focused
109
+ focused={props.focused !== false}
110
+ initialValue={props.initialValue || ""}
99
111
  wrapMode="word"
100
112
  keyBindings={PROMPT_KEYS}
101
113
  onSubmit={() => props.onSubmit(ref.current?.plainText ?? "")}
@@ -162,9 +174,23 @@ export function Spinner({ kind = "bar", color = theme.warn }: { kind?: "bar" | "
162
174
  )
163
175
  }
164
176
 
165
- export type Hint = { key: string; label: string; onPress?: () => void }
177
+ /** `drop`: the order hints give way when the row is too narrow, highest first; without it a hint always stays. */
178
+ export type Hint = { key: string; label: string; onPress?: () => void; drop?: number }
179
+
180
+ /** The hints that fit in `room` columns: whole hints give way, by their `drop`, rather than every label being squeezed. */
181
+ export function fitHints(items: Hint[], room: number): Hint[] {
182
+ const cells = (list: Hint[]) => list.reduce((n, h, i) => n + h.key.length + (h.label ? h.label.length + 1 : 0) + (i ? 2 : 0), 0)
183
+ let kept = [...items]
184
+ const order = items.filter((h) => h.drop !== undefined).sort((a, b) => b.drop! - a.drop!)
185
+ for (const h of order) {
186
+ if (cells(kept) <= room) break
187
+ kept = kept.filter((k) => k !== h)
188
+ }
189
+ return kept
190
+ }
166
191
 
167
- export function Hints({ width, items, left }: { width: number; items: Hint[]; left?: ReactNode }) {
192
+ export function Hints({ width, items: all, left, reserve = 0 }: { width: number; items: Hint[]; left?: ReactNode; reserve?: number }) {
193
+ const items = fitHints(all, width - reserve)
168
194
  return (
169
195
  <box style={{ width, height: 1, flexShrink: 0, flexDirection: "row", justifyContent: "space-between" }}>
170
196
  <box style={{ flexDirection: "row" }}>{left}</box>
@@ -25,15 +25,33 @@ export const SECTION = {
25
25
  /** Rows a panel's border takes: top and bottom. */
26
26
  export const PANEL_CHROME = 2
27
27
 
28
- /** A titled, rounded panel; `width` and `height` include the border. */
29
- export function Panel(props: { title: string; color: string; width?: number; height?: number; children: ReactNode }) {
28
+ /** The panels keys can move between (shift+tab), after the chat; the order they sit in on screen. */
29
+ export type Focus = "chat" | "plan" | "activity" | "files"
30
+ export const FOCUS_ORDER: Focus[] = ["chat", "plan", "activity", "files"]
31
+
32
+ /** The next panel shift+tab moves to, past any that are not on screen. */
33
+ export function nextFocus(at: Focus, shown: (f: Focus) => boolean): Focus {
34
+ for (let i = 1; i <= FOCUS_ORDER.length; i++) {
35
+ const f = FOCUS_ORDER[(FOCUS_ORDER.indexOf(at) + i) % FOCUS_ORDER.length]!
36
+ if (shown(f)) return f
37
+ }
38
+ return "chat"
39
+ }
40
+
41
+ /**
42
+ * A titled, rounded panel; `width` and `height` include the border. Focused, its border takes the section's colour and
43
+ * its keys show in the bottom border.
44
+ */
45
+ export function Panel(props: { title: string; color: string; width?: number; height?: number; focused?: boolean; keys?: string; onFocus?: () => void; children: ReactNode }) {
30
46
  return (
31
47
  <box
32
48
  border
33
49
  borderStyle="rounded"
34
- borderColor={theme.border}
50
+ borderColor={props.focused ? props.color : theme.border}
35
51
  title={` ${props.title} `}
36
52
  titleColor={props.color}
53
+ {...(props.focused && props.keys ? { bottomTitle: ` ${props.keys} `, bottomTitleAlignment: "right" as const } : {})}
54
+ {...(props.onFocus ? { onMouseDown: props.onFocus } : {})}
37
55
  style={{
38
56
  flexDirection: "column",
39
57
  paddingLeft: 1,
@@ -84,7 +102,23 @@ export function SessionPanel({ info, width }: { info: SessionInfo; width: number
84
102
  }
85
103
 
86
104
  /** The right pane's first line: who is working on what and for how long, and Stop while something runs. */
87
- export function StatusBar(props: { running: boolean; who: string; activity: string; elapsed: string; status: string; width: number; onStop: () => void }) {
105
+ /** "███████░░░░░ 5/8": how far a suite has got, in `cells` cells. */
106
+ export function progressBar(done: number, total: number, cells: number): [string, string] {
107
+ const filled = total ? Math.round((Math.min(done, total) / total) * cells) : 0
108
+ return ["█".repeat(filled), "░".repeat(cells - filled)]
109
+ }
110
+
111
+ export function StatusBar(props: {
112
+ running: boolean
113
+ who: string
114
+ activity: string
115
+ elapsed: string
116
+ status: string
117
+ width: number
118
+ onStop: () => void
119
+ /** A suite's cases: done so far, of how many. */
120
+ progress?: { done: number; total: number } | null
121
+ }) {
88
122
  const done = props.status === "done"
89
123
  const failed = props.status === "fail"
90
124
  const color = props.running ? theme.warn : done ? theme.ok : failed ? theme.err : theme.muted
@@ -97,7 +131,11 @@ export function StatusBar(props: { running: boolean; who: string; activity: stri
97
131
  : props.status === "stopped"
98
132
  ? "Last request stopped"
99
133
  : "Ready for your next request"
100
- const room = Math.max(10, props.width - props.who.length - (props.running ? 30 : 6))
134
+ const bar = props.running && props.progress && props.progress.total > 1 ? props.progress : null
135
+ const cells = 12
136
+ const barText = bar ? ` ${bar.done}/${bar.total} ` : ""
137
+ const room = Math.max(10, props.width - props.who.length - (props.running ? 30 : 6) - (bar ? cells + barText.length : 0))
138
+ const [full, empty] = bar ? progressBar(bar.done, bar.total, cells) : ["", ""]
101
139
  return (
102
140
  <box style={{ flexDirection: "row", justifyContent: "space-between", height: 1, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
103
141
  <box style={{ flexDirection: "row" }}>
@@ -109,6 +147,13 @@ export function StatusBar(props: { running: boolean; who: string; activity: stri
109
147
  </box>
110
148
  {props.running ? (
111
149
  <box style={{ flexDirection: "row" }}>
150
+ {bar ? (
151
+ <text>
152
+ <span fg={theme.ok}>{full}</span>
153
+ <span fg={theme.border}>{empty}</span>
154
+ <span fg={theme.muted}>{barText}</span>
155
+ </text>
156
+ ) : null}
112
157
  <text fg={theme.faint}>{`${props.elapsed} `}</text>
113
158
  <box onMouseDown={props.onStop} style={{ paddingLeft: 1, paddingRight: 1, backgroundColor: theme.warn }}>
114
159
  <text fg={theme.onButton}>{"■ Stop · esc"}</text>
@@ -132,9 +177,9 @@ const PROGRESS_MARK: Record<Progress["status"], [string, string]> = {
132
177
  skipped: ["■", theme.faint],
133
178
  }
134
179
 
135
- type PlanLine = { mark: string; color: string; text: string; textColor: string }
180
+ export type PlanLine = { mark: string; color: string; text: string; textColor: string }
136
181
 
137
- function planLines(s: Inspector): PlanLine[] {
182
+ export function planLines(s: Inspector): PlanLine[] {
138
183
  const lines: PlanLine[] = []
139
184
  for (const p of s.plan) {
140
185
  const [mark, color] = PLAN_MARK[p.status] || PLAN_MARK.pending!
@@ -154,20 +199,33 @@ export function planRows(s: Inspector, cap: number): number {
154
199
  return n ? Math.min(cap, n) + PANEL_CHROME : 0
155
200
  }
156
201
 
157
- /** The agent's to-do list, then the browser steps or suite cases; the latest that fit. */
158
- export function PlanPanel({ s, width, height }: { s: Inspector; width: number; height: number }) {
202
+ /** The rows of `total` a panel of `rows` shows: the latest, or, while a row is selected, a window that keeps it in view. */
203
+ export function windowRows(total: number, rows: number, selected?: number): [number, number] {
204
+ if (selected === undefined) return [Math.max(0, total - rows), total]
205
+ const first = Math.max(0, Math.min(selected - Math.floor(rows / 2), total - rows))
206
+ return [first, Math.min(total, first + rows)]
207
+ }
208
+
209
+ /** The agent's to-do list, then the browser steps or suite cases; the latest that fit, or around the selected one. */
210
+ export function PlanPanel(props: { s: Inspector; width: number; height: number; selected?: number; onFocus?: () => void }) {
211
+ const { s, width, height } = props
159
212
  const lines = planLines(s)
160
213
  const done = s.plan.filter((p) => p.status === "completed").length
161
214
  const count = s.plan.length ? ` · ${done} of ${s.plan.length}` : s.progress.items.length ? ` · ${clip(s.progress.title, 40)}` : ""
162
- const shown = lines.slice(-Math.max(1, height - PANEL_CHROME))
215
+ const [first, last] = windowRows(lines.length, Math.max(1, height - PANEL_CHROME), props.selected)
163
216
  return (
164
- <Panel title={`Plan${count}`} color={SECTION.plan} width={width} height={height}>
165
- {shown.map((l, i) => (
166
- <text key={i}>
167
- <span fg={l.color}>{`${l.mark} `}</span>
168
- <span fg={l.textColor}>{clip(l.text, Math.max(10, width - 8))}</span>
169
- </text>
170
- ))}
217
+ <Panel title={`Plan${count}`} color={SECTION.plan} width={width} height={height} focused={props.selected !== undefined} keys="↑↓ select · enter details · esc chat" onFocus={props.onFocus}>
218
+ {lines.slice(first, last).map((l, i) => {
219
+ const active = first + i === props.selected
220
+ return (
221
+ <box key={first + i} style={{ flexDirection: "row", height: 1, flexShrink: 0, backgroundColor: active ? theme.button : undefined }}>
222
+ <text>
223
+ <span fg={l.color}>{`${l.mark} `}</span>
224
+ <span fg={active ? theme.text : l.textColor}>{clip(l.text, Math.max(10, width - 8))}</span>
225
+ </text>
226
+ </box>
227
+ )
228
+ })}
171
229
  </Panel>
172
230
  )
173
231
  }
@@ -199,19 +257,63 @@ export function ActivityRow({ line }: { line: Line }) {
199
257
  )
200
258
  }
201
259
 
202
- /** Everything the agents do, newest at the bottom. */
203
- export function ActivityPanel(props: { lines: Line[]; scrollRef: RefObject<ScrollBoxRenderable | null>; width: number; height: number }) {
260
+ /** What the activity panel can narrow to, and the key for each while it has focus. */
261
+ export const ACTIVITY_FILTERS = [
262
+ { id: "all", label: "All", key: "a", kinds: null },
263
+ { id: "errors", label: "Errors", key: "e", kinds: ["err"] },
264
+ { id: "steps", label: "Steps", key: "s", kinds: ["ok", "err", "thought", "memory", "timing"] },
265
+ { id: "tools", label: "Tools", key: "t", kinds: ["tool", "file", "code"] },
266
+ ] as const
267
+ export type ActivityFilter = (typeof ACTIVITY_FILTERS)[number]["id"]
268
+
269
+ export function filterActivity(lines: Line[], filter: ActivityFilter): Line[] {
270
+ const kinds = ACTIVITY_FILTERS.find((f) => f.id === filter)?.kinds
271
+ return kinds ? lines.filter((l) => (kinds as readonly string[]).includes(l.kind)) : lines
272
+ }
273
+
274
+ /** Everything the agents do, newest at the bottom; focused, it scrolls and narrows to errors, steps, or tools. */
275
+ export function ActivityPanel(props: {
276
+ lines: Line[]
277
+ scrollRef: RefObject<ScrollBoxRenderable | null>
278
+ width: number
279
+ height: number
280
+ filter?: ActivityFilter
281
+ focused?: boolean
282
+ onFocus?: () => void
283
+ onFilter?: (f: ActivityFilter) => void
284
+ }) {
285
+ const filter = props.filter || "all"
286
+ const shown = filterActivity(props.lines, filter)
287
+ const label = ACTIVITY_FILTERS.find((f) => f.id === filter)!.label
288
+ const count = filter === "all" ? (shown.length ? ` · ${shown.length}` : "") : ` · ${label} ${shown.length} of ${props.lines.length}`
204
289
  return (
205
- <Panel title={`Activity${props.lines.length ? ` · ${props.lines.length}` : ""}`} color={SECTION.activity} width={props.width} height={props.height}>
206
- {props.lines.length ? (
290
+ <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}>
291
+ {props.focused || filter !== "all" ? (
292
+ <box style={{ flexDirection: "row", height: 1, flexShrink: 0 }}>
293
+ {ACTIVITY_FILTERS.map((f) => {
294
+ const on = f.id === filter
295
+ return (
296
+ <box key={f.id} onMouseDown={() => props.onFilter?.(f.id)} style={{ paddingLeft: 1, paddingRight: 1, marginRight: 1, backgroundColor: on ? SECTION.activity : theme.button }}>
297
+ <text>
298
+ <span fg={on ? theme.onButton : theme.text}>{f.key}</span>
299
+ <span fg={on ? theme.onButton : theme.muted}>{` ${f.label}`}</span>
300
+ </text>
301
+ </box>
302
+ )
303
+ })}
304
+ </box>
305
+ ) : null}
306
+ {shown.length ? (
207
307
  <scrollbox ref={props.scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1 }} focused={false}>
208
- {props.lines.map((line) => (
308
+ {shown.map((line) => (
209
309
  <ActivityRow key={line.id} line={line} />
210
310
  ))}
211
311
  </scrollbox>
212
312
  ) : (
213
313
  <box style={{ flexGrow: 1, justifyContent: "center", alignItems: "center" }}>
214
- <text fg={theme.faint}>{clip("Tool calls, browser steps, and the agents' reasoning show here", Math.max(10, props.width - 4))}</text>
314
+ <text fg={theme.faint}>
315
+ {clip(props.lines.length ? `Nothing under ${label} yet` : "Tool calls, browser steps, and the agents' reasoning show here", Math.max(10, props.width - 4))}
316
+ </text>
215
317
  </box>
216
318
  )}
217
319
  </Panel>
@@ -231,31 +333,37 @@ function fileLabel(path: string, room: number): [string, string] {
231
333
  return [name, dir && (dir.length <= space || space >= 12) ? tailCut(dir, space) : ""]
232
334
  }
233
335
 
234
- export function FilesPanel({ s, width, height }: { s: Inspector; width: number; height: number }) {
336
+ export function FilesPanel(props: { s: Inspector; width: number; height: number; selected?: number; onFocus?: () => void; onOpen?: (file: string) => void }) {
337
+ const { s, width, height } = props
235
338
  const files = filesByUse(s)
236
339
  const rows = height - PANEL_CHROME
237
340
  const room = Math.max(8, width - 10)
238
- const listed = files.length > rows ? files.slice(0, rows - 1) : files
341
+ const focused = props.selected !== undefined
342
+ // Unfocused: the most recent, and how many more; focused: a window that follows the selection through all of them.
343
+ const [first, last] = focused ? windowRows(files.length, rows, props.selected) : [0, files.length > rows ? rows - 1 : files.length]
239
344
  return (
240
- <Panel title={`Files${files.length ? ` · ${files.length}` : ""}`} color={SECTION.files} width={width} height={height}>
345
+ <Panel title={`Files${files.length ? ` · ${files.length}` : ""}`} color={SECTION.files} width={width} height={height} focused={focused} keys="enter open" onFocus={props.onFocus}>
241
346
  {files.length ? (
242
- listed.map((f) => {
347
+ files.slice(first, last).map((f, i) => {
243
348
  const [mark, color] = FILE_MARK[f.last]
244
349
  const [name, dir] = fileLabel(f.path, room)
245
350
  const times = f.reads + f.changes
351
+ const active = first + i === props.selected
246
352
  return (
247
- <text key={f.path}>
248
- <span fg={color}>{`${mark} `}</span>
249
- <span fg={f.changes ? theme.text : theme.muted}>{name}</span>
250
- {dir ? <span fg={theme.faint}>{` ${dir}`}</span> : null}
251
- {times > 1 ? <span fg={theme.faint}>{` ×${times}`}</span> : null}
252
- </text>
353
+ <box key={f.path} onMouseDown={() => props.onOpen?.(f.path)} style={{ flexDirection: "row", height: 1, flexShrink: 0, backgroundColor: active ? theme.button : undefined }}>
354
+ <text>
355
+ <span fg={color}>{`${mark} `}</span>
356
+ <span fg={f.changes || active ? theme.text : theme.muted}>{name}</span>
357
+ {dir ? <span fg={theme.faint}>{` ${dir}`}</span> : null}
358
+ {times > 1 ? <span fg={theme.faint}>{` ×${times}`}</span> : null}
359
+ </text>
360
+ </box>
253
361
  )
254
362
  })
255
363
  ) : (
256
364
  <text fg={theme.faint}>{"None yet"}</text>
257
365
  )}
258
- {files.length > listed.length ? <text fg={theme.faint}>{`+${files.length - listed.length} more`}</text> : null}
366
+ {!focused && files.length > last ? <text fg={theme.faint}>{`+${files.length - last} more`}</text> : null}
259
367
  </Panel>
260
368
  )
261
369
  }
@@ -0,0 +1,32 @@
1
+ import { existsSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { Produced } from "./transcript.tsx"
5
+
6
+ /**
7
+ * What to do next, after a request: a few one-click follow-ups from what it actually produced (a run that passed and
8
+ * has no test yet, a failed run, a report to open). Requests are put in the prompt to adjust; nothing is sent by a chip.
9
+ */
10
+
11
+ export type NextStep = { label: string; run: { kind: "insert"; text: string } | { kind: "open"; path: string } }
12
+
13
+ export function nextSteps(p: Produced, opts: { testsOn: boolean }): NextStep[] {
14
+ const steps: NextStep[] = []
15
+ const run = p.runs.at(-1)
16
+ const suite = p.suites.at(-1)
17
+ if (suite) {
18
+ if (existsSync(suite.html)) steps.push({ label: "Open the suite report", run: { kind: "open", path: suite.html } })
19
+ steps.push({ label: "Rerun the cases that failed", run: { kind: "insert", text: "Run the cases that failed in the last suite again" } })
20
+ } else if (run) {
21
+ const report = path.join(run.runDir, "report.html")
22
+ if (run.status === "complete" && !p.tests.some((t) => t.runDir === run.runDir)) steps.push({ label: "Write a test for this run", run: { kind: "insert", text: "Write a test for the last run" } })
23
+ if (run.status !== "complete") {
24
+ steps.push({ label: "Why did it fail?", run: { kind: "insert", text: "Why did the last run fail, and what should change?" } })
25
+ steps.push({ label: "Run it again, visible", run: { kind: "insert", text: "Run the last goal again in a visible browser" } })
26
+ }
27
+ if (existsSync(report)) steps.push({ label: "Open the report", run: { kind: "open", path: report } })
28
+ }
29
+ const test = p.tests.at(-1)
30
+ if (test?.result?.testFile && test.result.status === "passed") steps.unshift({ label: "Open the new test", run: { kind: "open", path: test.result.testFile } })
31
+ return steps.slice(0, 3)
32
+ }
@@ -0,0 +1,169 @@
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" }
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" }): 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) 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" } })
30
+ command("features", "What ForgeQE can do here", "/features", "and what each feature still needs")
31
+ command("auth", "Sign in to a system", "/auth", "Azure DevOps, GitHub, Jira, Confluence, TestRail, Zephyr, qTest, Xray")
32
+ command("auth-status", "What is signed in", "/auth status")
33
+ command("headed", opts.headed ? "Hide the browser window" : "Show the browser window", `/headed ${opts.headed ? "off" : "on"}`)
34
+ command("details", opts.detailsFull ? "Show progress only" : "Show everything the agents do", `/details ${opts.detailsFull ? "off" : "on"}`)
35
+ command("skills", "Skills each agent can use", "/skills")
36
+ command("logs", "Save this session's log to share", "/logs")
37
+ command("clear", "Clear the chat", "/clear")
38
+ command("help", "Help", "/help")
39
+ command("exit", "Quit ForgeQE", "/exit", undefined, "ctrl+c")
40
+ 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}` } })
41
+ 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 } })
42
+ return items
43
+ }
44
+
45
+ /**
46
+ * How well `query` matches `text`, case ignored; null when it does not. In order of strength: the text contains the
47
+ * query (best at the start of a word), every typed word starts a word of the text ("run sp" finds "Run … sprint"),
48
+ * or a short query is the text's initials ("rr" finds "Refine my request"). Letters merely scattered through the text
49
+ * do not match: they find everything and help nobody.
50
+ */
51
+ export function fuzzyScore(query: string, text: string): number | null {
52
+ const q = query.trim().toLowerCase()
53
+ if (!q) return 0
54
+ const t = text.toLowerCase()
55
+ const words = t.split(/[^a-z0-9]+/).filter(Boolean)
56
+ const at = t.indexOf(q)
57
+ if (at >= 0) return 100 + (at === 0 || /[^a-z0-9]/.test(t[at - 1]!) ? 20 : 0) - Math.min(20, at / 4)
58
+ const typed = q.split(/\s+/).filter(Boolean)
59
+ if (typed.length > 1 && typed.every((w) => words.some((x) => x.startsWith(w)))) return 70 - typed.length
60
+ if (typed.length === 1 && q.length >= 2 && q.length <= 5) {
61
+ if (words.map((w) => w[0]).join("").includes(q)) return 40
62
+ }
63
+ return null
64
+ }
65
+
66
+ /** The items that match, best first; with no query, in their own order. */
67
+ export function searchPalette(query: string, items: PaletteItem[], limit = 50): PaletteItem[] {
68
+ if (!query.trim()) return items.slice(0, limit)
69
+ return items
70
+ .map((item, order) => {
71
+ const own = fuzzyScore(query, item.title)
72
+ // Also what a command is typed as ("/auth", "/headed"), so people who know the command find it by its name.
73
+ const typedAs = item.run.kind === "command" ? item.run.text : ""
74
+ const more = fuzzyScore(query, `${item.title} ${item.detail || ""} ${typedAs} ${item.group}`)
75
+ const best = own === null ? (more === null ? null : more - 2) : Math.max(own + 2, more ?? own)
76
+ return { item, order, score: best }
77
+ })
78
+ .filter((x): x is { item: PaletteItem; order: number; score: number } => x.score !== null)
79
+ .sort((a, b) => b.score - a.score || a.order - b.order)
80
+ .slice(0, limit)
81
+ .map((x) => x.item)
82
+ }
83
+
84
+ export function Palette(props: { width: number; height: number; query: string; items: PaletteItem[]; selected: number; onPick: (item: PaletteItem) => void; onHover: (index: number) => void }) {
85
+ const boxWidth = Math.min(100, props.width - 8)
86
+ const rows = Math.max(4, Math.min(16, props.height - 12))
87
+ // Keep the selection in view: the window of rows slides with it.
88
+ const first = Math.max(0, Math.min(props.selected - Math.floor(rows / 2), props.items.length - rows))
89
+ const shown = props.items.slice(first, first + rows)
90
+ const room = boxWidth - 6
91
+ return (
92
+ <box
93
+ position="absolute"
94
+ zIndex={100}
95
+ border
96
+ borderStyle="rounded"
97
+ borderColor={theme.accent}
98
+ title=" Command palette "
99
+ titleColor={theme.accent}
100
+ bottomTitle=" ↑↓ select · enter choose · esc close "
101
+ bottomTitleAlignment="right"
102
+ style={{ top: 2, left: Math.floor((props.width - boxWidth) / 2), width: boxWidth, flexDirection: "column", backgroundColor: theme.panel, paddingLeft: 1, paddingRight: 1 }}
103
+ >
104
+ <text>
105
+ <span fg={theme.accent}>{"› "}</span>
106
+ {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>}
107
+ <span fg={theme.accent}>{"▏"}</span>
108
+ </text>
109
+ <text fg={theme.border}>{"─".repeat(Math.max(4, boxWidth - 4))}</text>
110
+ {shown.length ? (
111
+ shown.map((item, i) => {
112
+ const index = first + i
113
+ const active = index === props.selected
114
+ const keys = item.keys ? ` ${item.keys}` : ""
115
+ const titleRoom = Math.max(10, room - 10 - keys.length - (item.detail ? Math.min(34, item.detail.length) + 2 : 0))
116
+ return (
117
+ <box
118
+ key={item.id}
119
+ onMouseDown={() => props.onPick(item)}
120
+ onMouseOver={() => props.onHover(index)}
121
+ style={{ flexDirection: "row", justifyContent: "space-between", backgroundColor: active ? theme.button : theme.panel, flexShrink: 0 }}
122
+ >
123
+ <text>
124
+ <span fg={active ? theme.accent : theme.panel}>{"▌"}</span>
125
+ <span fg={GROUP_COLOR[item.group]}>{item.group.padEnd(8)}</span>
126
+ <span fg={active ? theme.text : theme.muted}>{clip(item.title, titleRoom)}</span>
127
+ {item.detail ? <span fg={theme.faint}>{` ${clip(item.detail, 34)}`}</span> : null}
128
+ </text>
129
+ <text fg={theme.faint}>{keys}</text>
130
+ </box>
131
+ )
132
+ })
133
+ ) : (
134
+ <text fg={theme.faint}>{" Nothing matches. Enter puts what you typed in the prompt; esc closes."}</text>
135
+ )}
136
+ {props.items.length > rows ? <text fg={theme.faint}>{` ${props.items.length} matches`}</text> : null}
137
+ </box>
138
+ )
139
+ }
140
+
141
+ /** The suggestions above the prompt while "/" or "@" is typed: same rows as the palette, so the two read as one. */
142
+ 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 }) {
143
+ const room = props.width - 6
144
+ return (
145
+ <box
146
+ border
147
+ borderStyle="rounded"
148
+ borderColor={theme.border}
149
+ title={` ${props.title} `}
150
+ titleColor={theme.accent}
151
+ bottomTitle=" tab complete · ↑↓ · esc "
152
+ bottomTitleAlignment="right"
153
+ style={{ flexDirection: "column", width: props.width, paddingLeft: 1, paddingRight: 1, backgroundColor: theme.panel, flexShrink: 0 }}
154
+ >
155
+ {props.items.map((item, i) => {
156
+ const active = i === props.selected
157
+ return (
158
+ <box key={`${item.label}-${i}`} onMouseDown={() => props.onPick(i)} onMouseOver={() => props.onHover(i)} style={{ flexDirection: "row", backgroundColor: active ? theme.button : theme.panel, flexShrink: 0 }}>
159
+ <text>
160
+ <span fg={active ? theme.accent : theme.panel}>{"▌"}</span>
161
+ <span fg={item.color}>{clip(item.label, Math.max(10, Math.floor(room / 2)))}</span>
162
+ {item.detail ? <span fg={theme.faint}>{` ${clip(item.detail, Math.max(8, room - item.label.length - 4))}`}</span> : null}
163
+ </text>
164
+ </box>
165
+ )
166
+ })}
167
+ </box>
168
+ )
169
+ }
@@ -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
+ }