@capagents/forgeqe 1.0.3 → 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.
- package/HOW_TO_USE.md +41 -3
- package/docs/user-guide.html +37 -4
- package/package.json +1 -1
- package/prompts/README.md +1 -0
- package/prompts/refine.md +17 -0
- package/src/capabilities/status.ts +4 -3
- package/src/refine.ts +116 -0
- package/src/tui/App.tsx +440 -31
- package/src/tui/components.tsx +29 -3
- package/src/tui/inspector.tsx +159 -89
- package/src/tui/next.ts +32 -0
- package/src/tui/palette.tsx +169 -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 +47 -5
- package/src/usage.ts +1 -1
package/src/tui/App.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ScrollBoxRenderable, Selection } from "@opentui/core"
|
|
2
2
|
import { useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
3
|
+
import { existsSync } from "node:fs"
|
|
3
4
|
import path from "node:path"
|
|
4
5
|
import { useEffect, useRef, useState } from "react"
|
|
5
6
|
|
|
@@ -23,21 +24,44 @@ import { buildSummary, shortPath } from "../summary.ts"
|
|
|
23
24
|
import { meterSnapshot, requestLine } from "../usage.ts"
|
|
24
25
|
import { cleanSelection, copyText } from "./clipboard.ts"
|
|
25
26
|
import { ChoicePanel, Footer, Hints, Logo, Prompt, Spinner, type Hint } from "./components.tsx"
|
|
27
|
+
import { setupChecklist, type SetupItem } from "./setup.ts"
|
|
26
28
|
import { EXAMPLES, TIPS, formatDuration, formatTokens, packageVersion, pick, shortCwd, theme } from "./theme.ts"
|
|
27
29
|
import { emptyProduced, Line, Produced, producedCard, TestsSetting, TranscriptLine } from "./transcript.tsx"
|
|
28
30
|
import { Dialog, dialogSpec } from "./dialogs.ts"
|
|
29
31
|
import { activityFromLog, clip, hostOf, modelLabel } from "./text.ts"
|
|
30
32
|
import { featuresText } from "../capabilities/render.ts"
|
|
33
|
+
import { Palette, paletteItems, searchPalette, SuggestList, type PaletteItem } from "./palette.tsx"
|
|
34
|
+
import { commandSuggestions, complete, lookupWorkItem, recentRuns, referenceSuggestions, repoFiles, triggerAt, type Suggestion, type WorkItem } from "./suggest.ts"
|
|
35
|
+
import { REFINE_CHOICES, RefinePanel, type RefineChoice, type RefineState } from "./refine-panel.tsx"
|
|
36
|
+
import { refine } from "../refine.ts"
|
|
37
|
+
import { nextSteps, type NextStep } from "./next.ts"
|
|
31
38
|
import { CAPABILITIES } from "../capabilities/registry.ts"
|
|
32
39
|
import { CONTEXT_LIMIT } from "../code/runtime.ts"
|
|
33
40
|
import { readKnowledge } from "../knowledge.ts"
|
|
34
41
|
import { externalMcp } from "../mcp/external.ts"
|
|
35
42
|
import { meterSince } from "../usage.ts"
|
|
36
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
ACTIVITY_FILTERS,
|
|
45
|
+
ActivityPanel,
|
|
46
|
+
ContextPanel,
|
|
47
|
+
FilesPanel,
|
|
48
|
+
nextFocus,
|
|
49
|
+
planLines,
|
|
50
|
+
PlanPanel,
|
|
51
|
+
planRows,
|
|
52
|
+
SessionPanel,
|
|
53
|
+
StatusBar,
|
|
54
|
+
TokensPanel,
|
|
55
|
+
type ActivityFilter,
|
|
56
|
+
type ContextInfo,
|
|
57
|
+
type Focus,
|
|
58
|
+
type SessionInfo,
|
|
59
|
+
} from "./inspector.tsx"
|
|
37
60
|
import {
|
|
38
61
|
addSkill,
|
|
39
62
|
caseDone,
|
|
40
63
|
caseStarted,
|
|
64
|
+
filesByUse,
|
|
41
65
|
newInspector,
|
|
42
66
|
newRequest,
|
|
43
67
|
paneFor,
|
|
@@ -72,6 +96,8 @@ export type TuiOptions = {
|
|
|
72
96
|
copy?: (text: string) => boolean
|
|
73
97
|
/** Show everything the agents do (their reasoning, every file read, engine logs); /details toggles it. */
|
|
74
98
|
verbose?: boolean
|
|
99
|
+
/** Replaces Refine's model session in tests. */
|
|
100
|
+
refine?: typeof refine
|
|
75
101
|
/** Replaces opening a page in the browser (sign-in) in tests. */
|
|
76
102
|
openUrl?: (url: string) => void
|
|
77
103
|
}
|
|
@@ -83,9 +109,14 @@ export const SPLIT_MIN = 120
|
|
|
83
109
|
export function App({ options }: { options: TuiOptions }) {
|
|
84
110
|
const renderer = useRenderer()
|
|
85
111
|
const { width, height } = useTerminalDimensions()
|
|
112
|
+
/** The terminal's width when a request ends (callbacks outlive the render they were made in). */
|
|
113
|
+
const widthRef = useRef(width)
|
|
114
|
+
widthRef.current = width
|
|
86
115
|
const scrollRef = useRef<ScrollBoxRenderable>(null)
|
|
87
116
|
const activityScroll = useRef<ScrollBoxRenderable>(null)
|
|
88
117
|
const submitRef = useRef<(value: string) => void>(() => {})
|
|
118
|
+
/** Refines what is in the prompt (phase two of the palette work wires it). */
|
|
119
|
+
const refineRef = useRef<() => void>(() => {})
|
|
89
120
|
const configRef = useRef<AppConfig | null>(null)
|
|
90
121
|
const abortRef = useRef<AbortController | null>(null)
|
|
91
122
|
const brainRef = useRef<Brain | null>(null)
|
|
@@ -107,6 +138,25 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
107
138
|
const codeRunDir = useRef("")
|
|
108
139
|
/** The right pane's live state: plan, steps or cases, files, context. */
|
|
109
140
|
const inspRef = useRef(newInspector())
|
|
141
|
+
/** The command palette: open, what is typed in it, and which match is selected. */
|
|
142
|
+
const [palette, setPalette] = useState<{ query: string; selected: number } | null>(null)
|
|
143
|
+
const paletteRef = useRef(palette)
|
|
144
|
+
paletteRef.current = palette
|
|
145
|
+
/** Refine (ctrl+r): what the prompt holds, the panel's state, and how to cancel it. */
|
|
146
|
+
const promptTextRef = useRef("")
|
|
147
|
+
const [refining, setRefining] = useState<RefineState | null>(null)
|
|
148
|
+
const refiningRef = useRef(refining)
|
|
149
|
+
refiningRef.current = refining
|
|
150
|
+
const refineAbort = useRef<AbortController | null>(null)
|
|
151
|
+
/** One-click follow-ups after a request, from what it produced. */
|
|
152
|
+
const [next, setNext] = useState<NextStep[]>([])
|
|
153
|
+
/** What the prompt holds, for suggestions; and the suggestion state: which is selected, which word was dismissed. */
|
|
154
|
+
const [promptText, setPromptText] = useState("")
|
|
155
|
+
const [sugg, setSugg] = useState<{ selected: number; dismissed?: string }>({ selected: 0 })
|
|
156
|
+
const [workItem, setWorkItem] = useState<WorkItem | null | undefined>(undefined)
|
|
157
|
+
const suggRef = useRef<{ list: Suggestion[]; trigger: ReturnType<typeof triggerAt> }>({ list: [], trigger: null })
|
|
158
|
+
/** Text the prompt starts with on its next remount (an example from the palette, a refined request). */
|
|
159
|
+
const [draft, setDraft] = useState("")
|
|
110
160
|
/** Set while a typed line is handled, so the shell's answers to it go to the chat. */
|
|
111
161
|
const answeringRef = useRef(false)
|
|
112
162
|
const [, setRev] = useState(0)
|
|
@@ -117,6 +167,15 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
117
167
|
const requests = useRef(0)
|
|
118
168
|
/** Under 120 columns there is room for one pane: ctrl+o switches between the chat and the details. */
|
|
119
169
|
const [narrowView, setNarrowView] = useState<"chat" | "details">("chat")
|
|
170
|
+
const setupRef = useRef<{ key: string; items: SetupItem[] }>({ key: "", items: [] })
|
|
171
|
+
// Which panel the keys go to (shift+tab moves on), the row selected in the plan and files, and the activity filter.
|
|
172
|
+
const [nav, setNavState] = useState<{ focus: Focus; plan: number; files: number; filter: ActivityFilter }>({ focus: "chat", plan: 0, files: 0, filter: "all" })
|
|
173
|
+
const navRef = useRef(nav)
|
|
174
|
+
// Keys can arrive faster than renders: each change builds on the ref, which the next key reads at once.
|
|
175
|
+
const setNav = (f: (n: typeof nav) => typeof nav) => {
|
|
176
|
+
navRef.current = f(navRef.current)
|
|
177
|
+
setNavState(navRef.current)
|
|
178
|
+
}
|
|
120
179
|
const contextInfo = useRef<{ key: string; knowledge: number; mcp: string[] }>({ key: "", knowledge: 0, mcp: [] })
|
|
121
180
|
|
|
122
181
|
const [lines, setLines] = useState<Line[]>([])
|
|
@@ -187,6 +246,19 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
187
246
|
}
|
|
188
247
|
/** An agent's tool call; files it writes or edits (the test script) stand out from reads and searches. */
|
|
189
248
|
const pushTool = (text: string, ok: boolean) => push(!ok ? "err" : /^(Writing|Editing) /.test(text) ? "file" : "tool", text)
|
|
249
|
+
/** A short note in the hint row that clears itself: "opened report.html". */
|
|
250
|
+
const flashTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
251
|
+
const flash = (text: string) => {
|
|
252
|
+
setCopied(text)
|
|
253
|
+
clearTimeout(flashTimer.current)
|
|
254
|
+
flashTimer.current = setTimeout(() => setCopied(""), 2500)
|
|
255
|
+
}
|
|
256
|
+
const openFile = (file: string) => {
|
|
257
|
+
const abs = path.resolve(file)
|
|
258
|
+
if (!existsSync(abs)) return flash(`${path.basename(file)} is no longer there`)
|
|
259
|
+
;(options.openUrl || openInBrowser)(`file://${abs}`)
|
|
260
|
+
flash(`opened ${path.basename(file)}`)
|
|
261
|
+
}
|
|
190
262
|
const pushProduced = () => {
|
|
191
263
|
const card = producedCard(producedRef.current, testsRef.current)
|
|
192
264
|
if (card) pushLine({ kind: "card", text: card.title, card })
|
|
@@ -197,6 +269,16 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
197
269
|
setTokens([...usedRef.current.values()].reduce((a, b) => a + b, 0))
|
|
198
270
|
}
|
|
199
271
|
|
|
272
|
+
// "@1761": look the work item up (once per id, after a pause in typing) so its title can be suggested.
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
const t = triggerAt(promptText)
|
|
275
|
+
const cfg = configRef.current
|
|
276
|
+
if (!t || t.kind !== "@" || !/^\d{2,}$/.test(t.token) || !cfg?.sources.ado) return
|
|
277
|
+
const id = Number(t.token)
|
|
278
|
+
const timer = setTimeout(() => void lookupWorkItem(cfg, id).then((item) => setWorkItem(item)), 300)
|
|
279
|
+
return () => clearTimeout(timer)
|
|
280
|
+
}, [promptText])
|
|
281
|
+
|
|
200
282
|
useEffect(() => {
|
|
201
283
|
if (status !== "running") return
|
|
202
284
|
const timer = setInterval(() => setTick((n) => n + 1), 1000)
|
|
@@ -389,6 +471,93 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
389
471
|
}
|
|
390
472
|
}
|
|
391
473
|
|
|
474
|
+
/** Everything the palette offers now, in its order. */
|
|
475
|
+
const paletteAll = (): PaletteItem[] =>
|
|
476
|
+
paletteItems({
|
|
477
|
+
running: abortRef.current !== null,
|
|
478
|
+
headed: headedRef.current,
|
|
479
|
+
detailsFull: detailRef.current === "full",
|
|
480
|
+
profiles: Object.keys(configRef.current?.profiles || {}),
|
|
481
|
+
active: configRef.current?.activeProfile || "",
|
|
482
|
+
narrow: widthRef.current < SPLIT_MIN,
|
|
483
|
+
view: narrowView,
|
|
484
|
+
})
|
|
485
|
+
const paletteMatches = (query: string) => searchPalette(query, paletteAll())
|
|
486
|
+
|
|
487
|
+
/** Puts text in the prompt to edit and send. */
|
|
488
|
+
const fillPrompt = (text: string) => {
|
|
489
|
+
promptTextRef.current = text
|
|
490
|
+
setPromptText(text)
|
|
491
|
+
setDraft(text)
|
|
492
|
+
setInputKey((k) => k + 1)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const setRefine = (next: RefineState | null) => {
|
|
496
|
+
refiningRef.current = next
|
|
497
|
+
setRefining(next)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Refines what is in the prompt; the user then decides what is sent. */
|
|
501
|
+
const startRefine = () => {
|
|
502
|
+
const text = promptTextRef.current.trim()
|
|
503
|
+
const cfg = configRef.current
|
|
504
|
+
if (!text) {
|
|
505
|
+
push("sys", "Type a request first, then press ctrl+r to refine it.", "chat")
|
|
506
|
+
return
|
|
507
|
+
}
|
|
508
|
+
const why = cfg ? brainUnavailable(cfg) : "Config is not loaded."
|
|
509
|
+
if (!cfg || why) {
|
|
510
|
+
push("err", `Refine needs ForgeQE's agent: ${why}`, "chat")
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
const controller = new AbortController()
|
|
514
|
+
refineAbort.current = controller
|
|
515
|
+
setRefine({ phase: "working", original: text })
|
|
516
|
+
void (options.refine || refine)({ config: cfg, cwd: sessionSetup(cfg, options.repo).cwd, text, recent: convoRef.current, signal: controller.signal })
|
|
517
|
+
.then((result) => {
|
|
518
|
+
if (controller.signal.aborted || refiningRef.current?.phase !== "working") return
|
|
519
|
+
setRefine({ phase: "done", original: text, result, choice: 0 })
|
|
520
|
+
})
|
|
521
|
+
.catch((err) => {
|
|
522
|
+
if (controller.signal.aborted) return
|
|
523
|
+
setRefine({ phase: "error", original: text, message: (err as Error).message })
|
|
524
|
+
fillPrompt(text)
|
|
525
|
+
})
|
|
526
|
+
.finally(() => {
|
|
527
|
+
if (refineAbort.current === controller) refineAbort.current = null
|
|
528
|
+
})
|
|
529
|
+
}
|
|
530
|
+
refineRef.current = startRefine
|
|
531
|
+
|
|
532
|
+
const chooseRefine = (id: RefineChoice) => {
|
|
533
|
+
const r = refiningRef.current
|
|
534
|
+
if (!r) return
|
|
535
|
+
setRefine(null)
|
|
536
|
+
if (r.phase !== "done") {
|
|
537
|
+
fillPrompt(r.original)
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
if (id === "use") submitRef.current(r.result.refined)
|
|
541
|
+
else fillPrompt(id === "edit" ? r.result.refined : r.original)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const takeNext = (step: NextStep) => {
|
|
545
|
+
setNext([])
|
|
546
|
+
if (step.run.kind === "insert") fillPrompt(step.run.text)
|
|
547
|
+
else (options.openUrl || openInBrowser)(`file://${path.resolve(step.run.path)}`)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const pickPalette = (item: PaletteItem) => {
|
|
551
|
+
paletteRef.current = null
|
|
552
|
+
setPalette(null)
|
|
553
|
+
const run = item.run
|
|
554
|
+
if (run.kind === "insert") fillPrompt(run.text)
|
|
555
|
+
else if (run.kind === "command") submitRef.current(run.text)
|
|
556
|
+
else if (run.id === "stop") openDialog("stop")
|
|
557
|
+
else if (run.id === "details" || run.id === "chat") setNarrowView(run.id)
|
|
558
|
+
else if (run.id === "refine") refineRef.current()
|
|
559
|
+
}
|
|
560
|
+
|
|
392
561
|
const cycleProfile = () => {
|
|
393
562
|
const cfg = configRef.current
|
|
394
563
|
if (!cfg || abortRef.current) return
|
|
@@ -399,6 +568,51 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
399
568
|
}
|
|
400
569
|
|
|
401
570
|
useKeyboard((key) => {
|
|
571
|
+
const pal = paletteRef.current
|
|
572
|
+
if (pal) {
|
|
573
|
+
// Keys can arrive faster than renders: every change builds on the latest state, never on this render's copy.
|
|
574
|
+
const edit = (f: (p: { query: string; selected: number }) => { query: string; selected: number } | null) => {
|
|
575
|
+
setPalette((p) => {
|
|
576
|
+
const next = p ? f(p) : p
|
|
577
|
+
paletteRef.current = next
|
|
578
|
+
return next
|
|
579
|
+
})
|
|
580
|
+
}
|
|
581
|
+
if (key.name === "escape" || (key.name === "p" && key.ctrl)) edit(() => null)
|
|
582
|
+
else if (key.name === "up") edit((p) => ({ ...p, selected: Math.max(0, p.selected - 1) }))
|
|
583
|
+
else if (key.name === "down") edit((p) => ({ ...p, selected: Math.min(Math.max(0, paletteMatches(p.query).length - 1), p.selected + 1) }))
|
|
584
|
+
else if (key.name === "return" || key.name === "enter" || key.name === "kpenter") {
|
|
585
|
+
const current = paletteRef.current!
|
|
586
|
+
const item = paletteMatches(current.query)[current.selected]
|
|
587
|
+
if (item) pickPalette(item)
|
|
588
|
+
else {
|
|
589
|
+
edit(() => null)
|
|
590
|
+
if (current.query.trim()) fillPrompt(current.query.trim())
|
|
591
|
+
}
|
|
592
|
+
} else if (key.name === "backspace") edit((p) => ({ query: p.query.slice(0, -1), selected: 0 }))
|
|
593
|
+
else if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence >= " ") {
|
|
594
|
+
const ch = key.sequence
|
|
595
|
+
edit((p) => ({ query: p.query + ch, selected: 0 }))
|
|
596
|
+
}
|
|
597
|
+
return
|
|
598
|
+
}
|
|
599
|
+
const r = refiningRef.current
|
|
600
|
+
if (r && !dialogRef.current && !(key.name === "c" && key.ctrl)) {
|
|
601
|
+
if (r.phase === "working") {
|
|
602
|
+
if (key.name === "escape") {
|
|
603
|
+
refineAbort.current?.abort()
|
|
604
|
+
setRefine(null)
|
|
605
|
+
fillPrompt(r.original)
|
|
606
|
+
}
|
|
607
|
+
} else if (r.phase === "error") {
|
|
608
|
+
if (key.name === "escape" || key.name === "return" || key.name === "enter") setRefine(null)
|
|
609
|
+
} else if (key.name === "left" || (key.name === "tab" && key.shift)) setRefine({ ...r, choice: (r.choice + REFINE_CHOICES.length - 1) % REFINE_CHOICES.length })
|
|
610
|
+
else if (key.name === "right" || key.name === "tab") setRefine({ ...r, choice: (r.choice + 1) % REFINE_CHOICES.length })
|
|
611
|
+
else if (key.name === "return" || key.name === "enter" || key.name === "kpenter") chooseRefine(REFINE_CHOICES[r.choice]!.id)
|
|
612
|
+
else if (key.name === "e") chooseRefine("edit")
|
|
613
|
+
else if (key.name === "escape") chooseRefine("keep")
|
|
614
|
+
return
|
|
615
|
+
}
|
|
402
616
|
const open = dialogRef.current
|
|
403
617
|
if (key.name === "c" && key.ctrl) {
|
|
404
618
|
if (open === "quit") quit()
|
|
@@ -426,8 +640,68 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
426
640
|
else if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence >= " ") setSecret(secretRef.current + key.sequence)
|
|
427
641
|
return
|
|
428
642
|
}
|
|
643
|
+
// The panels: shift+tab moves focus through those on screen; a focused panel takes the arrows, enter, and esc.
|
|
644
|
+
const panes = width >= SPLIT_MIN || narrowView === "details"
|
|
645
|
+
const insp = inspRef.current
|
|
646
|
+
const shown = (f: Focus) => f === "activity" || f === "files" || (f === "plan" && planLines(insp).length > 0) || (f === "chat" && width >= SPLIT_MIN)
|
|
647
|
+
if (panes && key.name === "tab" && key.shift && !entryRef.current) {
|
|
648
|
+
setNav((n) => ({ ...n, focus: nextFocus(n.focus, shown) }))
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
const at = navRef.current.focus
|
|
652
|
+
if (panes && at !== "chat" && shown(at)) {
|
|
653
|
+
const list = at === "plan" ? planLines(insp).length : at === "files" ? filesByUse(insp).length : 0
|
|
654
|
+
const move = (d: number) => setNav((n) => ({ ...n, [at]: Math.max(0, Math.min(list - 1, (n[at as "plan" | "files"] ?? 0) + d)) }))
|
|
655
|
+
const page = Math.max(3, Math.floor(height / 3))
|
|
656
|
+
if (key.name === "escape") return setNav((n) => ({ ...n, focus: width >= SPLIT_MIN ? "chat" : "activity" }))
|
|
657
|
+
if (at === "activity") {
|
|
658
|
+
const box = activityScroll.current
|
|
659
|
+
if (key.name === "up") return box?.scrollBy(-1)
|
|
660
|
+
if (key.name === "down") return box?.scrollBy(1)
|
|
661
|
+
if (key.name === "pageup") return box?.scrollBy(-page)
|
|
662
|
+
if (key.name === "pagedown") return box?.scrollBy(page)
|
|
663
|
+
const f = ACTIVITY_FILTERS.find((x) => x.key === key.name && !key.ctrl && !key.meta)
|
|
664
|
+
if (f) return setNav((n) => ({ ...n, filter: f.id }))
|
|
665
|
+
} else {
|
|
666
|
+
if (key.name === "up") return move(-1)
|
|
667
|
+
if (key.name === "down") return move(1)
|
|
668
|
+
if (key.name === "pageup") return move(-page)
|
|
669
|
+
if (key.name === "pagedown") return move(page)
|
|
670
|
+
if (key.name === "home") return move(-list)
|
|
671
|
+
if (key.name === "end") return move(list)
|
|
672
|
+
if (key.name === "return" || key.name === "enter" || key.name === "kpenter") {
|
|
673
|
+
if (at === "files") {
|
|
674
|
+
const f = filesByUse(insp)[Math.min(navRef.current.files, list - 1)]
|
|
675
|
+
if (f) openFile(f.path)
|
|
676
|
+
} else {
|
|
677
|
+
const l = planLines(insp)[Math.min(navRef.current.plan, list - 1)]
|
|
678
|
+
if (l) push("sys", `Plan ${l.mark} ${l.text}`, "chat")
|
|
679
|
+
}
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
// Anything typed goes back to the chat, where the prompt is.
|
|
684
|
+
if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence > " " && width >= SPLIT_MIN) return setNav((n) => ({ ...n, focus: "chat" }))
|
|
685
|
+
if (!key.ctrl && !key.meta) return
|
|
686
|
+
}
|
|
687
|
+
if (suggRef.current.list.length && suggRef.current.trigger && !entryRef.current) {
|
|
688
|
+
const n = suggRef.current.list.length
|
|
689
|
+
if (key.name === "up") return setSugg((x) => ({ ...x, selected: (x.selected + n - 1) % n }))
|
|
690
|
+
if (key.name === "down") return setSugg((x) => ({ ...x, selected: (x.selected + 1) % n }))
|
|
691
|
+
if (key.name === "tab" && !key.shift) return acceptSuggestion(Math.min(sugg.selected, n - 1))
|
|
692
|
+
if (key.name === "escape") return setSugg({ selected: 0, dismissed: `${suggRef.current.trigger.kind}${suggRef.current.trigger.token}` })
|
|
693
|
+
}
|
|
429
694
|
if (key.name === "tab" && !key.shift) cycleProfile()
|
|
430
|
-
if (key.name === "p" && key.ctrl)
|
|
695
|
+
if (key.name === "p" && key.ctrl) {
|
|
696
|
+
paletteRef.current = { query: "", selected: 0 }
|
|
697
|
+
setPalette(paletteRef.current)
|
|
698
|
+
}
|
|
699
|
+
if (key.meta && /^[1-3]$/.test(key.name || "") && showNext) {
|
|
700
|
+
const step = next[Number(key.name) - 1]
|
|
701
|
+
if (step) takeNext(step)
|
|
702
|
+
return
|
|
703
|
+
}
|
|
704
|
+
if (key.name === "r" && key.ctrl && !entryRef.current) startRefine()
|
|
431
705
|
if (key.name === "o" && key.ctrl && width < SPLIT_MIN) setNarrowView((v) => (v === "chat" ? "details" : "chat"))
|
|
432
706
|
if (key.name === "escape" && abortRef.current) openDialog("stop")
|
|
433
707
|
})
|
|
@@ -490,7 +764,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
490
764
|
push(event.ok ? "ok" : "err", briefText(event.text, detailRef.current))
|
|
491
765
|
// The action itself, without the result after a colon or the locator in brackets: "click "Search"".
|
|
492
766
|
const action = event.text.split(/: | {2}\[/)[0]!.trim()
|
|
493
|
-
|
|
767
|
+
// The step is named by its action, so the failure detail is what follows it: "element not found".
|
|
768
|
+
const why = event.text.slice(action.length).replace(/^[\s:\[\]]+/, "").trim() || event.text
|
|
769
|
+
stepOutcome(inspRef.current, event.ok, event.ok ? undefined : clip(why, 80), clip(action, 60))
|
|
494
770
|
bump()
|
|
495
771
|
}
|
|
496
772
|
return
|
|
@@ -696,6 +972,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
696
972
|
}
|
|
697
973
|
|
|
698
974
|
const finish = (finalStatus: string) => {
|
|
975
|
+
setNext(nextSteps(producedRef.current, { testsOn: testsRef.current.on }))
|
|
699
976
|
progressDone(inspRef.current, finalStatus === "stopped")
|
|
700
977
|
bump()
|
|
701
978
|
setStatus(finalStatus)
|
|
@@ -775,7 +1052,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
775
1052
|
} finally {
|
|
776
1053
|
pushProduced()
|
|
777
1054
|
// What this request used and cost in total: ForgeQE's agent, browser runs, Scribe, and the reviewer.
|
|
778
|
-
|
|
1055
|
+
// The split view's session and tokens panels show this; only the one-pane view needs it in the chat.
|
|
1056
|
+
const spent = widthRef.current < SPLIT_MIN ? requestLine(meterStart) : ""
|
|
779
1057
|
if (spent) push("sys", spent, "chat")
|
|
780
1058
|
finish(finalStatus)
|
|
781
1059
|
}
|
|
@@ -827,7 +1105,22 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
827
1105
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
828
1106
|
}, [])
|
|
829
1107
|
|
|
1108
|
+
const acceptSuggestion = (i: number) => {
|
|
1109
|
+
const { list, trigger } = suggRef.current
|
|
1110
|
+
const pick = list[i]
|
|
1111
|
+
if (!pick || !trigger) return
|
|
1112
|
+
setSugg({ selected: 0 })
|
|
1113
|
+
fillPrompt(complete(promptTextRef.current, trigger, pick.insert))
|
|
1114
|
+
}
|
|
1115
|
+
|
|
830
1116
|
const onSubmit = (value: string) => {
|
|
1117
|
+
// Enter with suggestions open takes the selected one, as tab does; a second enter sends. A command typed in full
|
|
1118
|
+
// ("/help") is sent at once.
|
|
1119
|
+
const full = suggRef.current.list.some((x) => x.kind === "command" && x.insert === value.trim())
|
|
1120
|
+
if (suggRef.current.list.length && suggRef.current.trigger && !full) {
|
|
1121
|
+
acceptSuggestion(Math.min(sugg.selected, suggRef.current.list.length - 1))
|
|
1122
|
+
return
|
|
1123
|
+
}
|
|
831
1124
|
// What a command answers (/help, /skills, "busy") is for the chat, even while a request runs.
|
|
832
1125
|
answeringRef.current = true
|
|
833
1126
|
try {
|
|
@@ -838,6 +1131,10 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
838
1131
|
}
|
|
839
1132
|
const submit = (value: string) => {
|
|
840
1133
|
const text = value.replace(/\t/g, "").trim()
|
|
1134
|
+
promptTextRef.current = ""
|
|
1135
|
+
setPromptText("")
|
|
1136
|
+
setDraft("")
|
|
1137
|
+
setNext([])
|
|
841
1138
|
setInputKey((k) => k + 1)
|
|
842
1139
|
const pending = entryRef.current
|
|
843
1140
|
if (pending) {
|
|
@@ -962,28 +1259,51 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
962
1259
|
: ""
|
|
963
1260
|
const rightColor = running ? theme.warn : status === "done" ? theme.ok : status === "fail" ? theme.err : theme.muted
|
|
964
1261
|
const footerRight = VERSION ? `ForgeQE ${VERSION}` : "ForgeQE"
|
|
1262
|
+
// Suggestions for what is being typed: "/" commands, "@" references (runs, files, an Azure DevOps item by id).
|
|
1263
|
+
const suggTrigger = triggerAt(promptText)
|
|
1264
|
+
const suggKey = suggTrigger ? `${suggTrigger.kind}${suggTrigger.token}` : ""
|
|
1265
|
+
const suggList: Suggestion[] =
|
|
1266
|
+
!suggTrigger || sugg.dismissed === suggKey || palette || refining || entry || dialog
|
|
1267
|
+
? []
|
|
1268
|
+
: suggTrigger.kind === "/"
|
|
1269
|
+
? commandSuggestions(paletteAll(), suggTrigger.token).slice(0, 8)
|
|
1270
|
+
: configRef.current
|
|
1271
|
+
? referenceSuggestions(suggTrigger.token, {
|
|
1272
|
+
runs: recentRuns(configRef.current.export.outDir),
|
|
1273
|
+
files: repoFiles(process.cwd()),
|
|
1274
|
+
item: workItem && String(workItem.id) === suggTrigger.token ? workItem : null,
|
|
1275
|
+
cwd: process.cwd(),
|
|
1276
|
+
})
|
|
1277
|
+
: []
|
|
1278
|
+
suggRef.current = { list: suggList, trigger: suggTrigger }
|
|
1279
|
+
const showNext = next.length > 0 && !running && !promptText.trim() && !palette && !refining && !dialog && !entry
|
|
965
1280
|
const home = lines.length === 0 && !running
|
|
1281
|
+
const paletteList = palette ? paletteMatches(palette.query) : []
|
|
1282
|
+
const paletteOverlay = palette ? (
|
|
1283
|
+
<Palette width={width} height={height} query={palette.query} items={paletteList} selected={Math.min(palette.selected, Math.max(0, paletteList.length - 1))} onPick={pickPalette} onHover={(i) => setPalette({ ...palette, selected: i })} />
|
|
1284
|
+
) : null
|
|
966
1285
|
|
|
967
1286
|
const closeHint: Hint = { key: "ctrl+c", label: "close", onPress: () => openDialog("quit") }
|
|
968
1287
|
const copiedHint: Hint[] = copied ? [{ key: `✓ ${copied}`, label: "" }] : []
|
|
969
1288
|
// The split view's right pane shows tokens, time, and the last result, so the hints under the prompt leave them out.
|
|
970
1289
|
const wide = width >= SPLIT_MIN
|
|
971
1290
|
const viewHint: Hint[] = wide ? [] : [{ key: "ctrl+o", label: "details", onPress: () => setNarrowView("details") }]
|
|
972
|
-
//
|
|
973
|
-
const runningViewHint: Hint[] = width >= 95 ? viewHint : []
|
|
1291
|
+
// Each hint's `drop` is when it gives way on a narrow row: the last run and profiles (also in the palette) go first.
|
|
974
1292
|
const idleHints: Hint[] = [
|
|
975
|
-
...copiedHint,
|
|
976
|
-
...(lastStats && !wide ? [{ key: `last run ${lastStats}`, label: "" }] : []),
|
|
977
|
-
...viewHint,
|
|
978
|
-
{ key: "
|
|
1293
|
+
...copiedHint.map((h) => ({ ...h, drop: 0 })),
|
|
1294
|
+
...(lastStats && !wide ? [{ key: `last run ${lastStats}`, label: "", drop: 5 }] : []),
|
|
1295
|
+
...viewHint.map((h) => ({ ...h, drop: 3 })),
|
|
1296
|
+
{ key: "ctrl+r", label: "refine", onPress: () => refineRef.current(), drop: 1 },
|
|
1297
|
+
...(wide ? [{ key: "shift+tab", label: "panels", onPress: () => setNav((n) => ({ ...n, focus: planLines(inspRef.current).length ? "plan" : "activity" })), drop: 4 }] : [{ key: "tab", label: "profiles", drop: 4 }]),
|
|
979
1298
|
{ key: "ctrl+p", label: "commands" },
|
|
980
1299
|
closeHint,
|
|
981
1300
|
]
|
|
982
1301
|
const runningHints: Hint[] = [
|
|
983
|
-
|
|
984
|
-
...(
|
|
985
|
-
//
|
|
986
|
-
...(wide ? [] : [{ key:
|
|
1302
|
+
// A note ("opened report.html") is the last to give way: it answers what was just pressed.
|
|
1303
|
+
...copiedHint.map((h) => ({ ...h, drop: 0 })),
|
|
1304
|
+
// The split view's panels show tokens and time; the one-pane view shows them here while there is room.
|
|
1305
|
+
...(wide ? [] : [{ key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "", drop: 3 }, ...viewHint.map((h) => ({ ...h, drop: 2 }))]),
|
|
1306
|
+
{ key: "ctrl+p", label: "commands", drop: 1 },
|
|
987
1307
|
closeHint,
|
|
988
1308
|
]
|
|
989
1309
|
const runningLeft = (
|
|
@@ -997,6 +1317,16 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
997
1317
|
)
|
|
998
1318
|
|
|
999
1319
|
const renderControls = (promptWidth: number, placeholder: string, rows: [number, number]) => {
|
|
1320
|
+
if (refining && !dialog) {
|
|
1321
|
+
return (
|
|
1322
|
+
<RefinePanel
|
|
1323
|
+
state={refining}
|
|
1324
|
+
width={promptWidth}
|
|
1325
|
+
onChoose={chooseRefine}
|
|
1326
|
+
onHover={(i) => refining.phase === "done" && setRefine({ ...refining, choice: i })}
|
|
1327
|
+
/>
|
|
1328
|
+
)
|
|
1329
|
+
}
|
|
1000
1330
|
if (dialog) {
|
|
1001
1331
|
const spec = dialogSpec(dialog, running, outDirLabel(), thinking, waitRef.current?.text, askRef.current?.q)
|
|
1002
1332
|
const wide = spec.options.reduce((n, o) => n + o.label.length + 3, 0) > promptWidth - 36
|
|
@@ -1016,6 +1346,45 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1016
1346
|
}
|
|
1017
1347
|
return (
|
|
1018
1348
|
<>
|
|
1349
|
+
{showNext && !suggList.length ? (
|
|
1350
|
+
// One row when the chips fit beside "Next", else one per line: a ragged wrap reads as broken.
|
|
1351
|
+
next.reduce((n, st) => n + st.label.length + 9, 6) <= promptWidth ? (
|
|
1352
|
+
<box style={{ flexDirection: "row", width: promptWidth, flexShrink: 0, marginBottom: 1 }}>
|
|
1353
|
+
<text fg={theme.muted}>{"Next "}</text>
|
|
1354
|
+
{next.map((step, i) => (
|
|
1355
|
+
<box key={step.label} onMouseDown={() => takeNext(step)} style={{ flexDirection: "row", paddingLeft: 1, paddingRight: 1, marginRight: 1, backgroundColor: theme.button }}>
|
|
1356
|
+
<text>
|
|
1357
|
+
<span fg={theme.text}>{step.label}</span>
|
|
1358
|
+
<span fg={theme.faint}>{` alt+${i + 1}`}</span>
|
|
1359
|
+
</text>
|
|
1360
|
+
</box>
|
|
1361
|
+
))}
|
|
1362
|
+
</box>
|
|
1363
|
+
) : (
|
|
1364
|
+
<box style={{ flexDirection: "column", width: promptWidth, flexShrink: 0, marginBottom: 1 }}>
|
|
1365
|
+
{next.map((step, i) => (
|
|
1366
|
+
<box key={step.label} onMouseDown={() => takeNext(step)} style={{ flexDirection: "row", justifyContent: "space-between", flexShrink: 0 }}>
|
|
1367
|
+
<text>
|
|
1368
|
+
<span fg={theme.muted}>{i ? " " : "Next "}</span>
|
|
1369
|
+
<span fg={theme.accent}>{"▸ "}</span>
|
|
1370
|
+
<span fg={theme.text}>{step.label}</span>
|
|
1371
|
+
</text>
|
|
1372
|
+
<text fg={theme.faint}>{`alt+${i + 1}`}</text>
|
|
1373
|
+
</box>
|
|
1374
|
+
))}
|
|
1375
|
+
</box>
|
|
1376
|
+
)
|
|
1377
|
+
) : null}
|
|
1378
|
+
{suggList.length && suggTrigger ? (
|
|
1379
|
+
<SuggestList
|
|
1380
|
+
title={suggTrigger.kind === "/" ? "Commands" : "References"}
|
|
1381
|
+
width={promptWidth}
|
|
1382
|
+
items={suggList.map((x) => ({ label: x.label, detail: x.detail, color: x.kind === "command" ? theme.accent : x.kind === "run" ? theme.thought : x.kind === "item" ? theme.ok : theme.memory }))}
|
|
1383
|
+
selected={Math.min(sugg.selected, suggList.length - 1)}
|
|
1384
|
+
onPick={acceptSuggestion}
|
|
1385
|
+
onHover={(i) => setSugg((x) => ({ ...x, selected: i }))}
|
|
1386
|
+
/>
|
|
1387
|
+
) : null}
|
|
1019
1388
|
<Prompt
|
|
1020
1389
|
width={promptWidth}
|
|
1021
1390
|
inputKey={inputKey}
|
|
@@ -1030,21 +1399,50 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1030
1399
|
right={right}
|
|
1031
1400
|
rightColor={rightColor}
|
|
1032
1401
|
compact={width >= SPLIT_MIN && !home}
|
|
1402
|
+
focused={!palette && !refining && (nav.focus === "chat" || (width < SPLIT_MIN && narrowView === "chat"))}
|
|
1403
|
+
initialValue={draft}
|
|
1404
|
+
onText={(t) => {
|
|
1405
|
+
promptTextRef.current = t
|
|
1406
|
+
setPromptText(t)
|
|
1407
|
+
}}
|
|
1033
1408
|
/>
|
|
1034
|
-
<Hints width={promptWidth} items={running ? runningHints : idleHints} left={running ? runningLeft : undefined} />
|
|
1409
|
+
<Hints width={promptWidth} items={running ? runningHints : idleHints} left={running ? runningLeft : undefined} reserve={running ? 20 : 0} />
|
|
1035
1410
|
</>
|
|
1036
1411
|
)
|
|
1037
1412
|
}
|
|
1038
1413
|
|
|
1039
1414
|
if (home) {
|
|
1040
1415
|
const promptWidth = Math.max(40, Math.min(width - 8, 120))
|
|
1416
|
+
// What is set up, while something is not: each open item says how, or runs the command that does it.
|
|
1417
|
+
const cfgHome = configRef.current
|
|
1418
|
+
const setupKey = cfgHome ? `${cfgHome.configPath}|${cfgHome.activeProfile}` : ""
|
|
1419
|
+
if (cfgHome && setupRef.current.key !== setupKey) setupRef.current = { key: setupKey, items: setupChecklist(cfgHome) }
|
|
1420
|
+
const setup = setupRef.current.items
|
|
1421
|
+
const open = setup.filter((i) => !i.done)
|
|
1422
|
+
const pickSetup = (i: SetupItem) => (i.command ? submitRef.current(i.command) : i.fix && push("sys", i.fix, "chat"))
|
|
1041
1423
|
return (
|
|
1042
1424
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1043
1425
|
<box style={{ flexDirection: "column", flexGrow: 1, alignItems: "center", justifyContent: "center" }}>
|
|
1044
1426
|
<Logo />
|
|
1045
1427
|
<box style={{ height: 2 }} />
|
|
1046
1428
|
{renderControls(promptWidth, `Describe a goal… "${example}"`, [4, Math.max(4, Math.min(12, height - 22))])}
|
|
1047
|
-
<box style={{ height: 3 }} />
|
|
1429
|
+
<box style={{ height: open.length ? 2 : 3 }} />
|
|
1430
|
+
{open.length ? (
|
|
1431
|
+
<box style={{ flexDirection: "column", alignItems: "center", flexShrink: 0, marginBottom: 1 }}>
|
|
1432
|
+
<box style={{ flexDirection: "row", flexShrink: 0 }}>
|
|
1433
|
+
<text fg={theme.muted}>{"Setup "}</text>
|
|
1434
|
+
{(setup.reduce((n, i) => n + i.label.length + 5, 7) <= width - 4 ? setup : open).map((i) => (
|
|
1435
|
+
<box key={i.id} onMouseDown={i.done ? undefined : () => pickSetup(i)} style={{ flexDirection: "row", paddingLeft: 1, paddingRight: 1, marginRight: 1, backgroundColor: i.done ? undefined : theme.button }}>
|
|
1436
|
+
<text>
|
|
1437
|
+
<span fg={i.done ? theme.ok : theme.warn}>{i.done ? "✓ " : "○ "}</span>
|
|
1438
|
+
<span fg={i.done ? theme.muted : theme.text}>{i.label}</span>
|
|
1439
|
+
</text>
|
|
1440
|
+
</box>
|
|
1441
|
+
))}
|
|
1442
|
+
</box>
|
|
1443
|
+
<text fg={theme.faint}>{`${setup.length - open.length} of ${setup.length} set up · click one to see how · /features shows what each feature needs`}</text>
|
|
1444
|
+
</box>
|
|
1445
|
+
) : null}
|
|
1048
1446
|
<text>
|
|
1049
1447
|
<span fg={theme.warn}>{"● "}</span>
|
|
1050
1448
|
<span fg={theme.text}>{"Tip "}</span>
|
|
@@ -1052,6 +1450,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1052
1450
|
</text>
|
|
1053
1451
|
</box>
|
|
1054
1452
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1453
|
+
{paletteOverlay}
|
|
1055
1454
|
</box>
|
|
1056
1455
|
)
|
|
1057
1456
|
}
|
|
@@ -1071,17 +1470,11 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1071
1470
|
}
|
|
1072
1471
|
const llm = cfgNow?.llm
|
|
1073
1472
|
const sessionInfo: SessionInfo = {
|
|
1074
|
-
profile,
|
|
1075
|
-
model: model || (cfgNow ? modelLabel(cfgNow) : ""),
|
|
1076
|
-
auth: !llm ? "–" : llm.auth === "entra" ? "Entra ID, no key" : llm.apiKeyEnv ? `API key in ${llm.apiKeyEnv}` : llm.provider === "mock" ? "offline demo" : llm.provider,
|
|
1473
|
+
model: cfgNow ? `${llm?.provider === "mock" ? "offline demo" : llm?.deployment || llm?.model || "none"}${profile ? ` (${profile})` : ""}` : "…",
|
|
1077
1474
|
cwd: shortCwd(),
|
|
1078
|
-
tests: testsRef.current.on ? "written to this folder" : requests.current ? `not written (${testsRef.current.why || "off"})` : "set on the first request",
|
|
1079
|
-
browser: `${headed ? "visible" : "headless"} · up to ${maxSteps} steps${options.url ? ` · ${hostOf(options.url)}` : ""}`,
|
|
1080
1475
|
...(cfgNow?.domain ? { domain: `${cfgNow.domain.industry}${cfgNow.domain.regulations.length ? ` (${cfgNow.domain.regulations.join(", ")})` : ""}` } : {}),
|
|
1081
|
-
...(llm?.priceWarning && llm.price ? { price: `${llm.price.input}/${llm.price.output} ${llm.price.currency} per M in/out from llms.json, not the list price` } : {}),
|
|
1082
1476
|
started: sessionStarted.current,
|
|
1083
1477
|
requests: requests.current,
|
|
1084
|
-
total: spent.roles.length ? { usage: spent.total, cost: spent.cost } : undefined,
|
|
1085
1478
|
}
|
|
1086
1479
|
const contextPanel: ContextInfo = {
|
|
1087
1480
|
tokens: insp.context,
|
|
@@ -1096,7 +1489,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1096
1489
|
? coding
|
|
1097
1490
|
? "The coding agent is writing a test · esc to stop"
|
|
1098
1491
|
: `${thinking ? "ForgeQE's agent" : "The browser agent"} is working · esc to stop`
|
|
1099
|
-
:
|
|
1492
|
+
: width >= SPLIT_MIN
|
|
1493
|
+
? "Ask ForgeQE · / commands · @ references"
|
|
1494
|
+
: "Ask ForgeQE: a goal, test cases, a test, a pipeline… (/ commands, @ references)"
|
|
1100
1495
|
const chatLines = lines.filter((l) => l.pane !== "activity")
|
|
1101
1496
|
const activityLines = lines.filter((l) => l.pane === "activity")
|
|
1102
1497
|
const stop = () => openDialog("stop")
|
|
@@ -1110,16 +1505,27 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1110
1505
|
const plan = planRows(insp, Math.max(3, Math.min(8, Math.floor(rowsAvailable / 5))))
|
|
1111
1506
|
const activityRows = Math.max(5, rowsAvailable - 2 - plan - bottom)
|
|
1112
1507
|
const third = Math.floor(paneWidth / 3)
|
|
1508
|
+
const focusOn = (f: Focus) => () => setNav((n) => ({ ...n, focus: f }))
|
|
1509
|
+
const sel = (f: "plan" | "files") => (nav.focus === f ? Math.min(nav[f], Math.max(0, (f === "plan" ? planLines(insp) : filesByUse(insp)).length - 1)) : undefined)
|
|
1113
1510
|
return (
|
|
1114
1511
|
<box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
|
|
1115
|
-
<StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} />
|
|
1512
|
+
<StatusBar running={running} who={insp.who} activity={activity} elapsed={elapsed} status={status} width={paneWidth} onStop={stop} progress={suiteProgress} />
|
|
1116
1513
|
<box style={{ height: 1, flexShrink: 0 }} />
|
|
1117
|
-
{plan ? <PlanPanel s={insp} width={paneWidth} height={plan} /> : null}
|
|
1118
|
-
<ActivityPanel
|
|
1514
|
+
{plan ? <PlanPanel s={insp} width={paneWidth} height={plan} selected={sel("plan")} onFocus={focusOn("plan")} /> : null}
|
|
1515
|
+
<ActivityPanel
|
|
1516
|
+
lines={activityLines}
|
|
1517
|
+
scrollRef={activityScroll}
|
|
1518
|
+
width={paneWidth}
|
|
1519
|
+
height={activityRows}
|
|
1520
|
+
filter={nav.filter}
|
|
1521
|
+
focused={nav.focus === "activity"}
|
|
1522
|
+
onFocus={focusOn("activity")}
|
|
1523
|
+
onFilter={(filter) => setNav((n) => ({ ...n, filter, focus: "activity" }))}
|
|
1524
|
+
/>
|
|
1119
1525
|
<box style={{ flexDirection: "row", height: bottom, flexShrink: 0 }}>
|
|
1120
|
-
<FilesPanel s={insp} width={third} height={bottom} />
|
|
1526
|
+
<FilesPanel s={insp} width={third} height={bottom} selected={sel("files")} onFocus={focusOn("files")} onOpen={openFile} />
|
|
1121
1527
|
<ContextPanel c={contextPanel} width={third} height={bottom} />
|
|
1122
|
-
<TokensPanel
|
|
1528
|
+
<TokensPanel total={spent.total} width={paneWidth - 2 * third} height={bottom} />
|
|
1123
1529
|
</box>
|
|
1124
1530
|
</box>
|
|
1125
1531
|
)
|
|
@@ -1146,7 +1552,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1146
1552
|
return (
|
|
1147
1553
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1148
1554
|
<box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
|
|
1149
|
-
<box style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
|
|
1555
|
+
<box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
|
|
1150
1556
|
<SessionPanel info={sessionInfo} width={leftWidth - 3} />
|
|
1151
1557
|
{transcript(chatLines, {})}
|
|
1152
1558
|
<box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
|
|
@@ -1156,6 +1562,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1156
1562
|
</box>
|
|
1157
1563
|
</box>
|
|
1158
1564
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1565
|
+
{paletteOverlay}
|
|
1159
1566
|
</box>
|
|
1160
1567
|
)
|
|
1161
1568
|
}
|
|
@@ -1170,10 +1577,11 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1170
1577
|
// Stop, a question, or "your turn": the same panel the chat view shows in place of the prompt.
|
|
1171
1578
|
controls(promptWidth)
|
|
1172
1579
|
) : (
|
|
1173
|
-
<Hints width={promptWidth} items={[{ key: "ctrl+o", label: "back to the chat", onPress: () => setNarrowView("chat") }, closeHint]} />
|
|
1580
|
+
<Hints width={promptWidth} items={[{ key: "ctrl+o", label: "back to the chat", onPress: () => setNarrowView("chat") }, { key: "shift+tab", label: "panels", drop: 1 }, closeHint]} />
|
|
1174
1581
|
)}
|
|
1175
1582
|
</box>
|
|
1176
1583
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1584
|
+
{paletteOverlay}
|
|
1177
1585
|
</box>
|
|
1178
1586
|
)
|
|
1179
1587
|
}
|
|
@@ -1182,6 +1590,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1182
1590
|
{transcript(lines, { paddingLeft: 2, paddingRight: 2, paddingTop: 1 })}
|
|
1183
1591
|
<box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>{controls(promptWidth)}</box>
|
|
1184
1592
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1593
|
+
{paletteOverlay}
|
|
1185
1594
|
</box>
|
|
1186
1595
|
)
|
|
1187
1596
|
}
|