@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.
- 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 +433 -22
- package/src/tui/components.tsx +29 -3
- package/src/tui/inspector.tsx +142 -34
- 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
|
}
|
|
@@ -89,6 +115,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
89
115
|
const scrollRef = useRef<ScrollBoxRenderable>(null)
|
|
90
116
|
const activityScroll = useRef<ScrollBoxRenderable>(null)
|
|
91
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>(() => {})
|
|
92
120
|
const configRef = useRef<AppConfig | null>(null)
|
|
93
121
|
const abortRef = useRef<AbortController | null>(null)
|
|
94
122
|
const brainRef = useRef<Brain | null>(null)
|
|
@@ -110,6 +138,25 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
110
138
|
const codeRunDir = useRef("")
|
|
111
139
|
/** The right pane's live state: plan, steps or cases, files, context. */
|
|
112
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("")
|
|
113
160
|
/** Set while a typed line is handled, so the shell's answers to it go to the chat. */
|
|
114
161
|
const answeringRef = useRef(false)
|
|
115
162
|
const [, setRev] = useState(0)
|
|
@@ -120,6 +167,15 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
120
167
|
const requests = useRef(0)
|
|
121
168
|
/** Under 120 columns there is room for one pane: ctrl+o switches between the chat and the details. */
|
|
122
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
|
+
}
|
|
123
179
|
const contextInfo = useRef<{ key: string; knowledge: number; mcp: string[] }>({ key: "", knowledge: 0, mcp: [] })
|
|
124
180
|
|
|
125
181
|
const [lines, setLines] = useState<Line[]>([])
|
|
@@ -190,6 +246,19 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
190
246
|
}
|
|
191
247
|
/** An agent's tool call; files it writes or edits (the test script) stand out from reads and searches. */
|
|
192
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
|
+
}
|
|
193
262
|
const pushProduced = () => {
|
|
194
263
|
const card = producedCard(producedRef.current, testsRef.current)
|
|
195
264
|
if (card) pushLine({ kind: "card", text: card.title, card })
|
|
@@ -200,6 +269,16 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
200
269
|
setTokens([...usedRef.current.values()].reduce((a, b) => a + b, 0))
|
|
201
270
|
}
|
|
202
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
|
+
|
|
203
282
|
useEffect(() => {
|
|
204
283
|
if (status !== "running") return
|
|
205
284
|
const timer = setInterval(() => setTick((n) => n + 1), 1000)
|
|
@@ -392,6 +471,93 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
392
471
|
}
|
|
393
472
|
}
|
|
394
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
|
+
|
|
395
561
|
const cycleProfile = () => {
|
|
396
562
|
const cfg = configRef.current
|
|
397
563
|
if (!cfg || abortRef.current) return
|
|
@@ -402,6 +568,51 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
402
568
|
}
|
|
403
569
|
|
|
404
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
|
+
}
|
|
405
616
|
const open = dialogRef.current
|
|
406
617
|
if (key.name === "c" && key.ctrl) {
|
|
407
618
|
if (open === "quit") quit()
|
|
@@ -429,8 +640,68 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
429
640
|
else if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence >= " ") setSecret(secretRef.current + key.sequence)
|
|
430
641
|
return
|
|
431
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
|
+
}
|
|
432
694
|
if (key.name === "tab" && !key.shift) cycleProfile()
|
|
433
|
-
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()
|
|
434
705
|
if (key.name === "o" && key.ctrl && width < SPLIT_MIN) setNarrowView((v) => (v === "chat" ? "details" : "chat"))
|
|
435
706
|
if (key.name === "escape" && abortRef.current) openDialog("stop")
|
|
436
707
|
})
|
|
@@ -493,7 +764,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
493
764
|
push(event.ok ? "ok" : "err", briefText(event.text, detailRef.current))
|
|
494
765
|
// The action itself, without the result after a colon or the locator in brackets: "click "Search"".
|
|
495
766
|
const action = event.text.split(/: | {2}\[/)[0]!.trim()
|
|
496
|
-
|
|
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))
|
|
497
770
|
bump()
|
|
498
771
|
}
|
|
499
772
|
return
|
|
@@ -699,6 +972,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
699
972
|
}
|
|
700
973
|
|
|
701
974
|
const finish = (finalStatus: string) => {
|
|
975
|
+
setNext(nextSteps(producedRef.current, { testsOn: testsRef.current.on }))
|
|
702
976
|
progressDone(inspRef.current, finalStatus === "stopped")
|
|
703
977
|
bump()
|
|
704
978
|
setStatus(finalStatus)
|
|
@@ -831,7 +1105,22 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
831
1105
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
832
1106
|
}, [])
|
|
833
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
|
+
|
|
834
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
|
+
}
|
|
835
1124
|
// What a command answers (/help, /skills, "busy") is for the chat, even while a request runs.
|
|
836
1125
|
answeringRef.current = true
|
|
837
1126
|
try {
|
|
@@ -842,6 +1131,10 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
842
1131
|
}
|
|
843
1132
|
const submit = (value: string) => {
|
|
844
1133
|
const text = value.replace(/\t/g, "").trim()
|
|
1134
|
+
promptTextRef.current = ""
|
|
1135
|
+
setPromptText("")
|
|
1136
|
+
setDraft("")
|
|
1137
|
+
setNext([])
|
|
845
1138
|
setInputKey((k) => k + 1)
|
|
846
1139
|
const pending = entryRef.current
|
|
847
1140
|
if (pending) {
|
|
@@ -966,28 +1259,51 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
966
1259
|
: ""
|
|
967
1260
|
const rightColor = running ? theme.warn : status === "done" ? theme.ok : status === "fail" ? theme.err : theme.muted
|
|
968
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
|
|
969
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
|
|
970
1285
|
|
|
971
1286
|
const closeHint: Hint = { key: "ctrl+c", label: "close", onPress: () => openDialog("quit") }
|
|
972
1287
|
const copiedHint: Hint[] = copied ? [{ key: `✓ ${copied}`, label: "" }] : []
|
|
973
1288
|
// The split view's right pane shows tokens, time, and the last result, so the hints under the prompt leave them out.
|
|
974
1289
|
const wide = width >= SPLIT_MIN
|
|
975
1290
|
const viewHint: Hint[] = wide ? [] : [{ key: "ctrl+o", label: "details", onPress: () => setNarrowView("details") }]
|
|
976
|
-
//
|
|
977
|
-
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.
|
|
978
1292
|
const idleHints: Hint[] = [
|
|
979
|
-
...copiedHint,
|
|
980
|
-
...(lastStats && !wide ? [{ key: `last run ${lastStats}`, label: "" }] : []),
|
|
981
|
-
...viewHint,
|
|
982
|
-
{ 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 }]),
|
|
983
1298
|
{ key: "ctrl+p", label: "commands" },
|
|
984
1299
|
closeHint,
|
|
985
1300
|
]
|
|
986
1301
|
const runningHints: Hint[] = [
|
|
987
|
-
|
|
988
|
-
...(
|
|
989
|
-
//
|
|
990
|
-
...(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 },
|
|
991
1307
|
closeHint,
|
|
992
1308
|
]
|
|
993
1309
|
const runningLeft = (
|
|
@@ -1001,6 +1317,16 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1001
1317
|
)
|
|
1002
1318
|
|
|
1003
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
|
+
}
|
|
1004
1330
|
if (dialog) {
|
|
1005
1331
|
const spec = dialogSpec(dialog, running, outDirLabel(), thinking, waitRef.current?.text, askRef.current?.q)
|
|
1006
1332
|
const wide = spec.options.reduce((n, o) => n + o.label.length + 3, 0) > promptWidth - 36
|
|
@@ -1020,6 +1346,45 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1020
1346
|
}
|
|
1021
1347
|
return (
|
|
1022
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}
|
|
1023
1388
|
<Prompt
|
|
1024
1389
|
width={promptWidth}
|
|
1025
1390
|
inputKey={inputKey}
|
|
@@ -1034,21 +1399,50 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1034
1399
|
right={right}
|
|
1035
1400
|
rightColor={rightColor}
|
|
1036
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
|
+
}}
|
|
1037
1408
|
/>
|
|
1038
|
-
<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} />
|
|
1039
1410
|
</>
|
|
1040
1411
|
)
|
|
1041
1412
|
}
|
|
1042
1413
|
|
|
1043
1414
|
if (home) {
|
|
1044
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"))
|
|
1045
1423
|
return (
|
|
1046
1424
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1047
1425
|
<box style={{ flexDirection: "column", flexGrow: 1, alignItems: "center", justifyContent: "center" }}>
|
|
1048
1426
|
<Logo />
|
|
1049
1427
|
<box style={{ height: 2 }} />
|
|
1050
1428
|
{renderControls(promptWidth, `Describe a goal… "${example}"`, [4, Math.max(4, Math.min(12, height - 22))])}
|
|
1051
|
-
<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}
|
|
1052
1446
|
<text>
|
|
1053
1447
|
<span fg={theme.warn}>{"● "}</span>
|
|
1054
1448
|
<span fg={theme.text}>{"Tip "}</span>
|
|
@@ -1056,6 +1450,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1056
1450
|
</text>
|
|
1057
1451
|
</box>
|
|
1058
1452
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1453
|
+
{paletteOverlay}
|
|
1059
1454
|
</box>
|
|
1060
1455
|
)
|
|
1061
1456
|
}
|
|
@@ -1094,7 +1489,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1094
1489
|
? coding
|
|
1095
1490
|
? "The coding agent is writing a test · esc to stop"
|
|
1096
1491
|
: `${thinking ? "ForgeQE's agent" : "The browser agent"} is working · esc to stop`
|
|
1097
|
-
:
|
|
1492
|
+
: width >= SPLIT_MIN
|
|
1493
|
+
? "Ask ForgeQE · / commands · @ references"
|
|
1494
|
+
: "Ask ForgeQE: a goal, test cases, a test, a pipeline… (/ commands, @ references)"
|
|
1098
1495
|
const chatLines = lines.filter((l) => l.pane !== "activity")
|
|
1099
1496
|
const activityLines = lines.filter((l) => l.pane === "activity")
|
|
1100
1497
|
const stop = () => openDialog("stop")
|
|
@@ -1108,14 +1505,25 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1108
1505
|
const plan = planRows(insp, Math.max(3, Math.min(8, Math.floor(rowsAvailable / 5))))
|
|
1109
1506
|
const activityRows = Math.max(5, rowsAvailable - 2 - plan - bottom)
|
|
1110
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)
|
|
1111
1510
|
return (
|
|
1112
1511
|
<box style={{ flexDirection: "column", width: paneWidth, height: rowsAvailable, flexShrink: 0 }}>
|
|
1113
|
-
<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} />
|
|
1114
1513
|
<box style={{ height: 1, flexShrink: 0 }} />
|
|
1115
|
-
{plan ? <PlanPanel s={insp} width={paneWidth} height={plan} /> : null}
|
|
1116
|
-
<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
|
+
/>
|
|
1117
1525
|
<box style={{ flexDirection: "row", height: bottom, flexShrink: 0 }}>
|
|
1118
|
-
<FilesPanel s={insp} width={third} height={bottom} />
|
|
1526
|
+
<FilesPanel s={insp} width={third} height={bottom} selected={sel("files")} onFocus={focusOn("files")} onOpen={openFile} />
|
|
1119
1527
|
<ContextPanel c={contextPanel} width={third} height={bottom} />
|
|
1120
1528
|
<TokensPanel total={spent.total} width={paneWidth - 2 * third} height={bottom} />
|
|
1121
1529
|
</box>
|
|
@@ -1144,7 +1552,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1144
1552
|
return (
|
|
1145
1553
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1146
1554
|
<box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
|
|
1147
|
-
<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 }}>
|
|
1148
1556
|
<SessionPanel info={sessionInfo} width={leftWidth - 3} />
|
|
1149
1557
|
{transcript(chatLines, {})}
|
|
1150
1558
|
<box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
|
|
@@ -1154,6 +1562,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1154
1562
|
</box>
|
|
1155
1563
|
</box>
|
|
1156
1564
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1565
|
+
{paletteOverlay}
|
|
1157
1566
|
</box>
|
|
1158
1567
|
)
|
|
1159
1568
|
}
|
|
@@ -1168,10 +1577,11 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1168
1577
|
// Stop, a question, or "your turn": the same panel the chat view shows in place of the prompt.
|
|
1169
1578
|
controls(promptWidth)
|
|
1170
1579
|
) : (
|
|
1171
|
-
<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]} />
|
|
1172
1581
|
)}
|
|
1173
1582
|
</box>
|
|
1174
1583
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1584
|
+
{paletteOverlay}
|
|
1175
1585
|
</box>
|
|
1176
1586
|
)
|
|
1177
1587
|
}
|
|
@@ -1180,6 +1590,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1180
1590
|
{transcript(lines, { paddingLeft: 2, paddingRight: 2, paddingTop: 1 })}
|
|
1181
1591
|
<box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>{controls(promptWidth)}</box>
|
|
1182
1592
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1593
|
+
{paletteOverlay}
|
|
1183
1594
|
</box>
|
|
1184
1595
|
)
|
|
1185
1596
|
}
|