@capagents/forgeqe 1.0.9 → 1.1.1
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 +50 -19
- package/docs/user-guide.html +49 -19
- package/forgeqe.example.yaml +1 -1
- package/package.json +1 -1
- package/prompts/brain.md +17 -3
- package/prompts/refine.md +16 -8
- package/src/brain/index.ts +9 -2
- package/src/code/runtime.ts +12 -0
- package/src/commands.ts +6 -1
- package/src/mcp/external.ts +28 -5
- package/src/mcp/server.ts +11 -1
- package/src/mcp/tools.ts +25 -2
- package/src/refine.ts +30 -14
- package/src/tui/App.tsx +126 -58
- package/src/tui/components.tsx +47 -55
- package/src/tui/landing.ts +101 -28
- package/src/tui/next.ts +1 -1
- package/src/tui/palette.tsx +1 -0
- package/src/tui/refine-panel.tsx +32 -7
- package/src/tui/shortcuts.tsx +1 -1
- package/src/tui/transcript.tsx +155 -150
package/src/mcp/tools.ts
CHANGED
|
@@ -98,6 +98,25 @@ export type ToolEvent =
|
|
|
98
98
|
| { type: "card"; card: Card }
|
|
99
99
|
/** write_report saved a report: its page and its Markdown. */
|
|
100
100
|
| { type: "report"; title: string; html: string; markdown: string }
|
|
101
|
+
/** A finished call of a tool that has no card of its own, as one line: "Azure DevOps API · QI-Claims/_apis/build/builds (top 5)". */
|
|
102
|
+
| { type: "call"; text: string; ok: boolean }
|
|
103
|
+
|
|
104
|
+
const API_LABEL: Record<string, string> = { azure_devops: "Azure DevOps", jira: "Jira", xray: "Xray", confluence: "Confluence" }
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A query_api call as a line: "Azure DevOps API · QI-Claims/_apis/release/releases (top 1, queryOrder descending)". The
|
|
108
|
+
* host and organization are left out, as are api-version and the start of a WIQL query.
|
|
109
|
+
*/
|
|
110
|
+
export function queryLine(args: Record<string, any>): string {
|
|
111
|
+
const name = `${API_LABEL[String(args.system)] || String(args.system || "API")} API`
|
|
112
|
+
if (args.wiql) return `${name} · WIQL (${clip(String(args.wiql).replace(/\s+/g, " ").replace(/^SELECT .*? FROM WorkItems WHERE /i, ""), 90)})`
|
|
113
|
+
if (args.query) return `${name} · GraphQL (${clip(String(args.query).replace(/\s+/g, " "), 90)})`
|
|
114
|
+
const raw = String(args.path || "")
|
|
115
|
+
const url = /^https?:\/\//.test(raw) ? raw.replace(/^https?:\/\/[^/]+\/(?:[^/]+\/(?=[^/]+\/_apis\/))?/, "") : raw.replace(/^\//, "")
|
|
116
|
+
const [where, search = ""] = url.split("?")
|
|
117
|
+
const params = [...new URLSearchParams(search)].filter(([k]) => k !== "api-version").map(([k, v]) => `${k.replace(/^\$/, "")} ${v}`)
|
|
118
|
+
return `${name} · ${decodeURIComponent(where || "")}${params.length ? ` (${clip(params.join(", "), 90)})` : ""}`
|
|
119
|
+
}
|
|
101
120
|
|
|
102
121
|
export type ToolContext = {
|
|
103
122
|
config: AppConfig
|
|
@@ -135,6 +154,8 @@ export type McpTool = {
|
|
|
135
154
|
description: string
|
|
136
155
|
inputSchema: Record<string, unknown>
|
|
137
156
|
run(args: Record<string, any>, ctx: ToolContext): Promise<unknown>
|
|
157
|
+
/** The call as one line in the conversation, for a tool that shows nothing else: the ForgeQE tools are hidden there. */
|
|
158
|
+
line?(args: Record<string, any>): string
|
|
138
159
|
}
|
|
139
160
|
|
|
140
161
|
const clip = (s: string | undefined, n: number) => {
|
|
@@ -563,6 +584,7 @@ export const TOOLS: McpTool[] = [
|
|
|
563
584
|
limit: { type: "integer", minimum: 1, maximum: 25, description: "Most pages to return (default 10)" },
|
|
564
585
|
},
|
|
565
586
|
},
|
|
587
|
+
line: (args) => `Confluence · ${args.cql ? `search (${clip(String(args.cql), 90)})` : `pages (${clip((Array.isArray(args.pages) ? args.pages : []).map(String).join(", "), 90)})`}`,
|
|
566
588
|
async run(args, ctx) {
|
|
567
589
|
const pages = Array.isArray(args.pages) ? args.pages.map(String) : undefined
|
|
568
590
|
return readConfluence(ctx.config.sources, { pages, cql: args.cql ? String(args.cql) : undefined, limit: Number(args.limit) || undefined })
|
|
@@ -571,7 +593,7 @@ export const TOOLS: McpTool[] = [
|
|
|
571
593
|
{
|
|
572
594
|
name: "query_api",
|
|
573
595
|
description:
|
|
574
|
-
"Read anything from Azure DevOps, Jira, Xray, or Confluence through their APIs with the credentials in ForgeQE's settings: work items, bugs, stories, queries, pipeline runs, issues, JQL searches, Xray tests and executions, Confluence pages and spaces. Use it for what the other tools and connected MCP servers don't cover, or when a system has no MCP server. Read-only: GET requests, WIQL searches for Azure DevOps work items, and GraphQL queries for Xray Cloud.",
|
|
596
|
+
"Read anything from Azure DevOps, Jira, Xray, or Confluence through their APIs with the credentials in ForgeQE's settings: work items, bugs, stories, queries, pipeline runs, classic releases and deployments, issues, JQL searches, Xray tests and executions, Confluence pages and spaces. Use it for what the other tools and connected MCP servers don't cover, or when a system has no MCP server: before the browser, and never curl or scripts in the shell, which have no credentials. Read-only: GET requests, WIQL searches for Azure DevOps work items, and GraphQL queries for Xray Cloud.",
|
|
575
597
|
inputSchema: {
|
|
576
598
|
type: "object",
|
|
577
599
|
properties: {
|
|
@@ -579,7 +601,7 @@ export const TOOLS: McpTool[] = [
|
|
|
579
601
|
path: {
|
|
580
602
|
type: "string",
|
|
581
603
|
description:
|
|
582
|
-
"azure_devops: REST path under the organization URL, e.g. Shop/_apis/wit/workitems/12?$expand=all (api-version=7.1 is added when missing). jira: REST path under the site URL, e.g. /rest/api/3/search/jql?jql=project%3DSHOP&fields=summary,status (Server/Data Center: /rest/api/2/..., Xray Server: /rest/raven/1.0/...). confluence: REST path under the Confluence URL, e.g. /rest/api/space or /api/v2/pages/123/children (Cloud).",
|
|
604
|
+
"azure_devops: REST path under the organization URL, e.g. Shop/_apis/wit/workitems/12?$expand=all or Shop/_apis/build/builds?$top=5 (api-version=7.1 is added when missing). Classic releases are on the release host: pass the full URL, e.g. https://vsrm.dev.azure.com/<org>/Shop/_apis/release/releases?$top=10, .../_apis/release/definitions, or .../_apis/release/deployments. jira: REST path under the site URL, e.g. /rest/api/3/search/jql?jql=project%3DSHOP&fields=summary,status (Server/Data Center: /rest/api/2/..., Xray Server: /rest/raven/1.0/...). confluence: REST path under the Confluence URL, e.g. /rest/api/space or /api/v2/pages/123/children (Cloud).",
|
|
583
605
|
},
|
|
584
606
|
query: { type: "string", description: "xray (Cloud): a GraphQL query, e.g. { getTests(jql: \"project = SHOP\", limit: 10) { results { jira(fields: [\"key\"]) } } }" },
|
|
585
607
|
wiql: {
|
|
@@ -592,6 +614,7 @@ export const TOOLS: McpTool[] = [
|
|
|
592
614
|
},
|
|
593
615
|
required: ["system"],
|
|
594
616
|
},
|
|
617
|
+
line: queryLine,
|
|
595
618
|
async run(args, ctx) {
|
|
596
619
|
return queryApi(ctx.config.sources, { system: args.system, path: args.path, query: args.query, variables: args.variables, wiql: args.wiql, project: args.project })
|
|
597
620
|
},
|
package/src/refine.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { CAPABILITIES } from "./capabilities/registry.ts"
|
|
|
10
10
|
import { allStatuses } from "./capabilities/status.ts"
|
|
11
11
|
import { domainNotes } from "./domain.ts"
|
|
12
12
|
import type { CodeEvent } from "./types.ts"
|
|
13
|
+
import { steScore } from "./ste.ts"
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* The shell's Refine (ctrl+r): what the user typed becomes a precise request for ForgeQE's agent, checked against what
|
|
@@ -20,13 +21,18 @@ import type { CodeEvent } from "./types.ts"
|
|
|
20
21
|
/** The only tools the refiner may call: each reads, none changes anything or starts a run. */
|
|
21
22
|
export const REFINE_TOOLS = ["capabilities", "query_api", "find_test_cases", "list_sprints", "run_details"]
|
|
22
23
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* The refined request as a task specification, laid out as a system prompt: the role the agent takes, the objective,
|
|
26
|
+
* the context, the steps, the rules, the output format, and the objective again in one sentence at the end.
|
|
27
|
+
*/
|
|
28
|
+
export type RefinedSpec = { role: string; objective: string; context: string[]; steps: string[]; rules: string[]; output: string; summary: string }
|
|
25
29
|
|
|
26
30
|
export type Refined = {
|
|
27
|
-
/** The request that is sent: the spec written out (
|
|
31
|
+
/** The request that is sent: the spec written out (Role, Objective, Context, Steps, Rules, Output format, Summary). */
|
|
28
32
|
refined: string
|
|
29
33
|
spec?: RefinedSpec
|
|
34
|
+
/** The share of the request's sentences that keep the ASD-STE100 rules, 0 to 1. */
|
|
35
|
+
ste?: number
|
|
30
36
|
grounded: { fact: string; checked: boolean }[]
|
|
31
37
|
assumed: string[]
|
|
32
38
|
missing: string[]
|
|
@@ -37,11 +43,13 @@ const lines = { type: "array", items: { type: "string" } } as const
|
|
|
37
43
|
export const REFINE_SCHEMA = {
|
|
38
44
|
type: "object",
|
|
39
45
|
properties: {
|
|
40
|
-
|
|
46
|
+
role: { type: "string", description: "The role the agent takes for this task, as one description, e.g. A release analyst who reads Azure DevOps deployments" },
|
|
47
|
+
objective: { type: "string", description: "One instruction that starts with a verb: what the agent must do" },
|
|
41
48
|
context: { ...lines, description: "Facts the agent needs, one per line: system, project, work items, files, runs, with exact names and ids" },
|
|
42
49
|
steps: { ...lines, description: "The actions in sequence, one per step; empty for a request with one action" },
|
|
43
50
|
rules: { ...lines, description: "Limits the user gave or the setup sets, one per line" },
|
|
44
|
-
|
|
51
|
+
output: { type: "string", description: "The output format: what the agent gives back and how it is laid out (sections, a table, a file)" },
|
|
52
|
+
summary: { type: "string", description: "The objective again, in one short sentence, as the last line" },
|
|
45
53
|
grounded: {
|
|
46
54
|
type: "array",
|
|
47
55
|
items: { type: "object", properties: { fact: { type: "string" }, checked: { type: "boolean" } }, required: ["fact", "checked"] },
|
|
@@ -49,7 +57,7 @@ export const REFINE_SCHEMA = {
|
|
|
49
57
|
assumed: { type: "array", items: { type: "string" } },
|
|
50
58
|
missing: { type: "array", items: { type: "string" } },
|
|
51
59
|
},
|
|
52
|
-
required: ["
|
|
60
|
+
required: ["role", "objective", "context", "steps", "rules", "output", "summary", "grounded", "assumed", "missing"],
|
|
53
61
|
} as const
|
|
54
62
|
|
|
55
63
|
const REFINE_AGENT = {
|
|
@@ -64,11 +72,14 @@ const REFINE_AGENT = {
|
|
|
64
72
|
|
|
65
73
|
/** The spec as the request ForgeQE's agent gets: labelled parts, empty ones left out. */
|
|
66
74
|
export function composeRefined(spec: RefinedSpec): string {
|
|
67
|
-
const parts = [
|
|
75
|
+
const parts: string[] = []
|
|
76
|
+
if (spec.role) parts.push(`Role: ${spec.role}`)
|
|
77
|
+
parts.push(`Objective: ${spec.objective}`)
|
|
68
78
|
if (spec.context.length) parts.push(["Context:", ...spec.context.map((c) => `- ${c}`)].join("\n"))
|
|
69
79
|
if (spec.steps.length) parts.push(["Steps:", ...spec.steps.map((st, i) => `${i + 1}. ${st}`)].join("\n"))
|
|
70
80
|
if (spec.rules.length) parts.push(["Rules:", ...spec.rules.map((r) => `- ${r}`)].join("\n"))
|
|
71
|
-
if (spec.
|
|
81
|
+
if (spec.output) parts.push(`Output format: ${spec.output}`)
|
|
82
|
+
if (spec.summary) parts.push(`Summary: ${spec.summary}`)
|
|
72
83
|
return parts.join("\n\n")
|
|
73
84
|
}
|
|
74
85
|
|
|
@@ -97,13 +108,18 @@ export function refineRequest(text: string, recent: { user: string; reply: strin
|
|
|
97
108
|
}
|
|
98
109
|
|
|
99
110
|
/** Keeps only what the schema allows, so the shell never shows a malformed answer. */
|
|
100
|
-
export function cleanRefined(r: (Partial<Refined> & Partial<RefinedSpec>) | null | undefined, original: string): Refined {
|
|
111
|
+
export function cleanRefined(r: (Partial<Refined> & Partial<RefinedSpec> & { goal?: string; result?: string }) | null | undefined, original: string): Refined {
|
|
101
112
|
const strings = (v: unknown) => (Array.isArray(v) ? v.map(String).map((s) => s.trim()).filter(Boolean) : [])
|
|
102
|
-
const
|
|
103
|
-
|
|
113
|
+
const text = (v: unknown) => String(v || "").trim()
|
|
114
|
+
// goal and result: the names an older refine prompt (a project's own copy) gives the objective and the output.
|
|
115
|
+
const objective = text(r?.objective) || text(r?.goal)
|
|
116
|
+
const spec: RefinedSpec | undefined = objective
|
|
117
|
+
? { role: text(r?.role), objective, context: strings(r?.context), steps: strings(r?.steps), rules: strings(r?.rules), output: text(r?.output) || text(r?.result), summary: text(r?.summary) }
|
|
118
|
+
: undefined
|
|
119
|
+
const refined = spec ? composeRefined(spec) : text(r?.refined) || original
|
|
104
120
|
return {
|
|
105
|
-
refined
|
|
106
|
-
...(spec ? { spec } : {}),
|
|
121
|
+
refined,
|
|
122
|
+
...(spec ? { spec, ste: steScore(refined).score } : {}),
|
|
107
123
|
grounded: Array.isArray(r?.grounded) ? r!.grounded.filter((g) => g && String(g.fact || "").trim()).map((g) => ({ fact: String(g.fact).trim(), checked: g.checked === true })) : [],
|
|
108
124
|
assumed: strings(r?.assumed),
|
|
109
125
|
missing: strings(r?.missing),
|
|
@@ -150,7 +166,7 @@ export async function refine(opts: {
|
|
|
150
166
|
})
|
|
151
167
|
try {
|
|
152
168
|
let out = await session.prompt(refineRequest(opts.text, opts.recent))
|
|
153
|
-
if (!out.report && !out.error && !opts.signal?.aborted) out = await session.prompt("Give the structured result:
|
|
169
|
+
if (!out.report && !out.error && !opts.signal?.aborted) out = await session.prompt("Give the structured result: role, objective, context, steps, rules, output, summary, grounded, assumed, and missing.")
|
|
154
170
|
if (!out.report) throw new Error(out.error ? `Refine failed: ${out.error}` : "Refine gave no result.")
|
|
155
171
|
return cleanRefined(out.report, opts.text)
|
|
156
172
|
} finally {
|
package/src/tui/App.tsx
CHANGED
|
@@ -26,8 +26,9 @@ import { meterSnapshot, requestLine } from "../usage.ts"
|
|
|
26
26
|
import { cleanSelection, copyText } from "./clipboard.ts"
|
|
27
27
|
import { ButtonBar, ChoicePanel, Footer, Hints, Logo, Prompt, Spinner, type Button, type Hint } from "./components.tsx"
|
|
28
28
|
import { setupChecklist, type SetupItem } from "./setup.ts"
|
|
29
|
+
import { initWorkspace } from "../init.ts"
|
|
29
30
|
import { ShortcutsPanel } from "./shortcuts.tsx"
|
|
30
|
-
import { ago, projectLine, setupSentence, recentWork, startActions, type RecentItem, type StartAction } from "./landing.ts"
|
|
31
|
+
import { ago, getStarted, projectLine, setupSentence, type Fact, type GetStarted, recentWork, startActions, type RecentItem, type StartAction } from "./landing.ts"
|
|
31
32
|
import { TIPS, formatDuration, formatTokens, packageVersion, shortCwd, theme } from "./theme.ts"
|
|
32
33
|
import { emptyProduced, Line, Produced, producedCard, TestsSetting, TranscriptLine } from "./transcript.tsx"
|
|
33
34
|
import { Dialog, dialogSpec } from "./dialogs.ts"
|
|
@@ -35,7 +36,7 @@ import { activityFromLog, clip, hostOf, modelLabel } from "./text.ts"
|
|
|
35
36
|
import { featuresText } from "../capabilities/render.ts"
|
|
36
37
|
import { Palette, paletteItems, searchPalette, SuggestList, type PaletteItem } from "./palette.tsx"
|
|
37
38
|
import { commandSuggestions, complete, lookupWorkItem, recentRuns, referenceSuggestions, repoFiles, triggerAt, type Suggestion, type WorkItem } from "./suggest.ts"
|
|
38
|
-
import { REFINE_CHOICES, RefinePanel, type RefineChoice, type RefineState } from "./refine-panel.tsx"
|
|
39
|
+
import { initialChoice, REFINE_CHOICES, RefinePanel, type RefineChoice, type RefineState } from "./refine-panel.tsx"
|
|
39
40
|
import { refine } from "../refine.ts"
|
|
40
41
|
import { nextSteps, type NextStep } from "./next.ts"
|
|
41
42
|
import { CAPABILITIES } from "../capabilities/registry.ts"
|
|
@@ -178,8 +179,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
178
179
|
/** Under 120 columns there is room for one pane: ctrl+o switches between the chat and the details. */
|
|
179
180
|
const [narrowView, setNarrowView] = useState<"chat" | "details">("chat")
|
|
180
181
|
// The split view's chat at the full width, for reading a long answer; the panels come back with Split view.
|
|
181
|
-
const [chatWide, setChatWide] = useState(
|
|
182
|
-
const setupRef = useRef<{ key: string; items: SetupItem[]; start: StartAction[]; recent: RecentItem[] }>({ key: "", items: [], start: [], recent: [] })
|
|
182
|
+
const [chatWide, setChatWide] = useState(true)
|
|
183
|
+
const setupRef = useRef<{ key: string; items: SetupItem[]; start: StartAction[]; recent: RecentItem[]; first: GetStarted[]; facts: Fact[] }>({ key: "", items: [], start: [], recent: [], first: [], facts: [] })
|
|
183
184
|
// Which panel the keys go to (shift+tab moves on), the row selected in the plan and files, and the activity filter.
|
|
184
185
|
const [nav, setNavState] = useState<{ focus: Focus; plan: number; files: number; filter: ActivityFilter }>({ focus: "chat", plan: 0, files: 0, filter: "all" })
|
|
185
186
|
const navRef = useRef(nav)
|
|
@@ -518,6 +519,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
518
519
|
}
|
|
519
520
|
|
|
520
521
|
/** Refines what is in the prompt; the user then decides what is sent. */
|
|
522
|
+
const refineCount = useRef(0)
|
|
521
523
|
const startRefine = () => {
|
|
522
524
|
const text = promptTextRef.current.trim()
|
|
523
525
|
const cfg = configRef.current
|
|
@@ -533,10 +535,19 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
533
535
|
const controller = new AbortController()
|
|
534
536
|
refineAbort.current = controller
|
|
535
537
|
setRefine({ phase: "working", original: text })
|
|
536
|
-
|
|
538
|
+
// Refine is a model session of its own: its tokens count in the session total, under a key per refine.
|
|
539
|
+
const key = `refine-${++refineCount.current}`
|
|
540
|
+
const started = Date.now()
|
|
541
|
+
let used = 0
|
|
542
|
+
const onEvent = (e: CodeEvent) => {
|
|
543
|
+
if (e.kind !== "usage") return
|
|
544
|
+
used = e.usage.input + e.usage.output
|
|
545
|
+
useTokens(key, used)
|
|
546
|
+
}
|
|
547
|
+
void (options.refine || refine)({ config: cfg, cwd: sessionSetup(cfg, options.repo).cwd, text, recent: convoRef.current, signal: controller.signal, onEvent })
|
|
537
548
|
.then((result) => {
|
|
538
549
|
if (controller.signal.aborted || refiningRef.current?.phase !== "working") return
|
|
539
|
-
setRefine({ phase: "done", original: text, result, choice:
|
|
550
|
+
setRefine({ phase: "done", original: text, result, choice: initialChoice(result.missing), ...(used ? { tokens: used } : {}), ms: Date.now() - started })
|
|
540
551
|
})
|
|
541
552
|
.catch((err) => {
|
|
542
553
|
if (controller.signal.aborted) return
|
|
@@ -668,7 +679,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
668
679
|
// The panels: shift+tab moves focus through those on screen; a focused panel takes the arrows, enter, and esc.
|
|
669
680
|
const panes = (width >= SPLIT_MIN && !chatWide) || (width < SPLIT_MIN && narrowView === "details")
|
|
670
681
|
const insp = inspRef.current
|
|
671
|
-
const
|
|
682
|
+
const panelsOpen = width >= SPLIT_MIN && !chatWide
|
|
683
|
+
const shown = (f: Focus) => f === "activity" || f === "files" || (f === "plan" && planLines(insp).length > 0) || (f === "chat" && panelsOpen)
|
|
672
684
|
if (panes && key.name === "tab" && key.shift && !entryRef.current) {
|
|
673
685
|
// Consumed here: the prompt can take focus within this key press and must not also act on it.
|
|
674
686
|
key.preventDefault()
|
|
@@ -682,7 +694,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
682
694
|
const list = at === "plan" ? planLines(insp).length : at === "files" ? filesByUse(insp).length : 0
|
|
683
695
|
const move = (d: number) => setNav((n) => ({ ...n, [at]: Math.max(0, Math.min(list - 1, (n[at as "plan" | "files"] ?? 0) + d)) }))
|
|
684
696
|
const page = Math.max(3, Math.floor(height / 3))
|
|
685
|
-
if (key.name === "escape") return setNav((n) => ({ ...n, focus:
|
|
697
|
+
if (key.name === "escape") return setNav((n) => ({ ...n, focus: panelsOpen ? "chat" : "activity" }))
|
|
686
698
|
if (at === "activity") {
|
|
687
699
|
const box = activityScroll.current
|
|
688
700
|
if (key.name === "up") return box?.scrollBy(-1)
|
|
@@ -710,7 +722,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
710
722
|
}
|
|
711
723
|
}
|
|
712
724
|
// Anything typed goes back to the chat, where the prompt is.
|
|
713
|
-
if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence > " " &&
|
|
725
|
+
if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence > " " && panelsOpen) return setNav((n) => ({ ...n, focus: "chat" }))
|
|
714
726
|
if (!key.ctrl && !key.meta) return
|
|
715
727
|
}
|
|
716
728
|
// ? on an empty prompt: every key in one panel; esc or ? closes it.
|
|
@@ -726,8 +738,11 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
726
738
|
}
|
|
727
739
|
// The start screen's actions: alt+1 to alt+4 put one in the prompt.
|
|
728
740
|
if (key.meta && /^[1-4]$/.test(key.name || "") && linesRef.current === 0 && !abortRef.current) {
|
|
729
|
-
const
|
|
741
|
+
const n = Number(key.name) - 1
|
|
742
|
+
const a = setupRef.current.start[n]
|
|
743
|
+
const g = setupRef.current.first[n]
|
|
730
744
|
if (a) fillPrompt(a.request)
|
|
745
|
+
else if (!setupRef.current.start.length && g) g.command ? submitRef.current(g.command) : g.fix && push("sys", g.fix, "chat")
|
|
731
746
|
return
|
|
732
747
|
}
|
|
733
748
|
if (suggRef.current.list.length && suggRef.current.trigger && !entryRef.current) {
|
|
@@ -748,7 +763,11 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
748
763
|
return
|
|
749
764
|
}
|
|
750
765
|
if (key.name === "r" && key.ctrl && !entryRef.current) startRefine()
|
|
751
|
-
|
|
766
|
+
// ctrl+o: the panels (plan, activity, files, context, tokens) beside the chat on a wide terminal, in its place on a narrow one.
|
|
767
|
+
if (key.name === "o" && key.ctrl) {
|
|
768
|
+
if (width >= SPLIT_MIN) setChatWide((w) => !w)
|
|
769
|
+
else setNarrowView((v) => (v === "chat" ? "details" : "chat"))
|
|
770
|
+
}
|
|
752
771
|
if (key.name === "escape" && abortRef.current) openDialog("stop")
|
|
753
772
|
})
|
|
754
773
|
|
|
@@ -905,6 +924,10 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
905
924
|
case "info":
|
|
906
925
|
push(e.ok === undefined ? "code" : e.ok ? "ok" : "err", e.text)
|
|
907
926
|
return
|
|
927
|
+
case "call":
|
|
928
|
+
pushTool(e.text, e.ok)
|
|
929
|
+
if (!aborted()) setActivity(`Agent: ${clip(e.text, 60)}`)
|
|
930
|
+
return
|
|
908
931
|
case "agent": {
|
|
909
932
|
const ev = e.event
|
|
910
933
|
inspect(ev)
|
|
@@ -1104,7 +1127,9 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1104
1127
|
pushProduced()
|
|
1105
1128
|
// What this request used and cost in total: ForgeQE's agent, browser runs, Scribe, and the reviewer.
|
|
1106
1129
|
// The split view's session and tokens panels show this; only the one-pane view needs it in the chat.
|
|
1107
|
-
|
|
1130
|
+
// The footer and the tokens panel show what a request used; the conversation stays about the work.
|
|
1131
|
+
const spent = ""
|
|
1132
|
+
void meterStart
|
|
1108
1133
|
if (spent) push("sys", spent, "chat")
|
|
1109
1134
|
finish(finalStatus, followUps)
|
|
1110
1135
|
}
|
|
@@ -1129,6 +1154,19 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1129
1154
|
else void askAgent(text, cfg)
|
|
1130
1155
|
}
|
|
1131
1156
|
|
|
1157
|
+
/** Reads forgeqe.yaml and llms.json again (after /init wrote them), keeping the session going. */
|
|
1158
|
+
const reloadConfig = () => {
|
|
1159
|
+
let cfg = loadConfig({ configPath: options.configPath, profile: options.profile })
|
|
1160
|
+
if (options.profile) cfg = selectProfile(cfg, options.profile)
|
|
1161
|
+
configRef.current = cfg
|
|
1162
|
+
setProfile(cfg.activeProfile)
|
|
1163
|
+
setModel(modelLabel(cfg))
|
|
1164
|
+
setupRef.current.key = ""
|
|
1165
|
+
contextInfo.current.key = ""
|
|
1166
|
+
closeBrain()
|
|
1167
|
+
bump()
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1132
1170
|
useEffect(() => {
|
|
1133
1171
|
if (booted.current) return
|
|
1134
1172
|
booted.current = true
|
|
@@ -1198,7 +1236,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1198
1236
|
if (!text) return
|
|
1199
1237
|
// Setup notes come before the request, so they do not read as its answer; the split view shows them in its session panel.
|
|
1200
1238
|
const notes = priceNotesRef.current.splice(0)
|
|
1201
|
-
|
|
1239
|
+
// Price notes stay out of the conversation: forgeqe models and the reports say what a run cost.
|
|
1240
|
+
void notes
|
|
1202
1241
|
push("user", text)
|
|
1203
1242
|
const cmd = parseCommand(text)
|
|
1204
1243
|
if (cmd.type === "help") {
|
|
@@ -1240,6 +1279,17 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1240
1279
|
}
|
|
1241
1280
|
return
|
|
1242
1281
|
}
|
|
1282
|
+
if (cmd.type === "init") {
|
|
1283
|
+
// forgeqe init in this folder, then the session reads what it wrote: the start screen updates at once.
|
|
1284
|
+
try {
|
|
1285
|
+
const r = initWorkspace(process.cwd(), {})
|
|
1286
|
+
push("sys", [r.created.length ? `Created in ${shortCwd()}:` : "Nothing to create: this folder is set up.", ...r.created.map((f) => ` ✚ ${path.relative(process.cwd(), f) || f}`), ...r.notes].join("\n"), "chat")
|
|
1287
|
+
reloadConfig()
|
|
1288
|
+
} catch (err) {
|
|
1289
|
+
push("err", (err as Error).message, "chat")
|
|
1290
|
+
}
|
|
1291
|
+
return
|
|
1292
|
+
}
|
|
1243
1293
|
if (cmd.type === "features") {
|
|
1244
1294
|
if (configRef.current) push("sys", featuresText(configRef.current))
|
|
1245
1295
|
else push("err", "Config is not loaded.")
|
|
@@ -1309,7 +1359,11 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1309
1359
|
? "headed"
|
|
1310
1360
|
: ""
|
|
1311
1361
|
const rightColor = running ? theme.warn : status === "done" ? theme.ok : status === "fail" ? theme.err : theme.muted
|
|
1312
|
-
const
|
|
1362
|
+
const footerVersion = VERSION ? `ForgeQE ${VERSION}` : "ForgeQE"
|
|
1363
|
+
// In the conversation, the footer is the status line: the model, what the session used, and the version.
|
|
1364
|
+
const sessionTokens = meterSince(sessionMeter.current).total
|
|
1365
|
+
const usedTokens = sessionTokens.input + sessionTokens.output
|
|
1366
|
+
const footerRight = lines.length ? [configRef.current ? modelLabel(configRef.current) : model, usedTokens ? `${formatTokens(usedTokens)} tokens` : "", footerVersion].filter(Boolean).join(" · ") : footerVersion
|
|
1313
1367
|
// Suggestions for what is being typed: "/" commands, "@" references (runs, files, an Azure DevOps item by id).
|
|
1314
1368
|
const suggTrigger = triggerAt(promptText)
|
|
1315
1369
|
const suggKey = suggTrigger ? `${suggTrigger.kind}${suggTrigger.token}` : ""
|
|
@@ -1336,8 +1390,10 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1336
1390
|
|
|
1337
1391
|
const closeHint: Hint = { key: "ctrl+c", label: "close", onPress: () => openDialog("quit") }
|
|
1338
1392
|
const copiedHint: Hint[] = copied ? [{ key: `✓ ${copied}`, label: "" }] : []
|
|
1339
|
-
//
|
|
1393
|
+
// One column by default, as in Claude Code; ctrl+o (or Panels) opens the plan, activity, files, context, and
|
|
1394
|
+
// tokens beside it on a wide terminal, or in its place on a narrow one.
|
|
1340
1395
|
const wide = width >= SPLIT_MIN
|
|
1396
|
+
const panelsOpen = wide && !chatWide
|
|
1341
1397
|
const openPalette = () => {
|
|
1342
1398
|
paletteRef.current = { query: "", selected: 0 }
|
|
1343
1399
|
setPalette(paletteRef.current)
|
|
@@ -1347,25 +1403,26 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1347
1403
|
// `drop`: the order they give way on a narrow row; Refine, Search, and Stop always stay.
|
|
1348
1404
|
const refineButton: Button = { icon: "✦", label: "Refine", keys: "ctrl+r", color: theme.memory, onPress: () => refineRef.current(), active: Boolean(promptText.trim()) }
|
|
1349
1405
|
const searchButton: Button = { icon: "⌕", label: "Search", keys: "ctrl+p", color: theme.accent, onPress: openPalette }
|
|
1350
|
-
// The view:
|
|
1351
|
-
//
|
|
1406
|
+
// The view: with the panels open, moving into them and closing them sit in their status line; otherwise Panels
|
|
1407
|
+
// (Details on a narrow terminal) opens them.
|
|
1352
1408
|
const toggleWide = () => setChatWide((w) => !w)
|
|
1353
1409
|
const paneButtons: Button[] = [
|
|
1354
|
-
{ icon: "▦", label: "
|
|
1355
|
-
{ icon: "
|
|
1410
|
+
{ icon: "▦", label: "Focus panels", keys: "shift+tab", color: theme.thought, onPress: focusPanels, drop: 2 },
|
|
1411
|
+
{ icon: "✕", label: "Close", keys: "ctrl+o", color: theme.muted, onPress: toggleWide, drop: 1 },
|
|
1356
1412
|
]
|
|
1357
1413
|
const viewButtons: Button[] = wide
|
|
1358
1414
|
? chatWide
|
|
1359
|
-
? [{ icon: "
|
|
1415
|
+
? [{ icon: "▦", label: "Panels", keys: "ctrl+o", color: theme.thought, onPress: toggleWide, drop: 2 }]
|
|
1360
1416
|
: []
|
|
1361
1417
|
: [{ icon: "◧", label: "Details", keys: "ctrl+o", color: theme.thought, onPress: () => setNarrowView("details"), drop: 1 }]
|
|
1362
1418
|
const idleButtons: Button[] = [refineButton, searchButton, ...viewButtons, ...(wide ? [] : [{ icon: "⇄", label: "Profile", keys: "tab", color: theme.muted, onPress: cycleProfile, drop: 4 }])]
|
|
1363
1419
|
const runningButtons: Button[] = [{ icon: "■", label: "Stop", keys: "esc", color: theme.warn, onPress: () => openDialog("stop") }, searchButton, ...viewButtons]
|
|
1364
1420
|
const shortcutsHint: Hint = { key: "?", label: "shortcuts", onPress: () => setShortcuts(true) }
|
|
1365
1421
|
// ? is offered where there is room for it: the start screen of a terminal 110 columns or wider; it works everywhere.
|
|
1366
|
-
const idleHints: Hint[] = [...copiedHint, ...(
|
|
1422
|
+
const idleHints: Hint[] = [...copiedHint, ...(home && width >= 110 ? [shortcutsHint] : []), closeHint]
|
|
1367
1423
|
// The one-pane view has no tokens panel, so the tokens and time show here.
|
|
1368
|
-
|
|
1424
|
+
// While a request runs, the line above the prompt says what is happening, for how long, and with how many tokens.
|
|
1425
|
+
const runningHints: Hint[] = [...copiedHint, closeHint]
|
|
1369
1426
|
const runningLeft = (
|
|
1370
1427
|
<box style={{ flexDirection: "row", marginRight: 1 }}>
|
|
1371
1428
|
<Spinner kind="bar" />
|
|
@@ -1454,8 +1511,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1454
1511
|
model={model}
|
|
1455
1512
|
right={right}
|
|
1456
1513
|
rightColor={rightColor}
|
|
1457
|
-
compact={
|
|
1458
|
-
focused={!palette && !refining && (nav.focus === "chat"
|
|
1514
|
+
compact={!home}
|
|
1515
|
+
focused={!palette && !refining && (panelsOpen ? nav.focus === "chat" : wide || narrowView === "chat")}
|
|
1459
1516
|
initialValue={draft}
|
|
1460
1517
|
onText={(t) => {
|
|
1461
1518
|
promptTextRef.current = t
|
|
@@ -1479,24 +1536,37 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1479
1536
|
const cfgHome = configRef.current
|
|
1480
1537
|
const setupKey = cfgHome ? `${cfgHome.configPath}|${cfgHome.activeProfile}` : ""
|
|
1481
1538
|
if (cfgHome && setupRef.current.key !== setupKey) {
|
|
1539
|
+
const items = setupChecklist(cfgHome)
|
|
1540
|
+
const recent = recentWork(path.resolve(cfgHome.export.outDir))
|
|
1541
|
+
let signIns: Record<string, string> = {}
|
|
1542
|
+
try {
|
|
1543
|
+
// "Azure DevOps (PAT)": how each system's server signs in, as the session panel says it.
|
|
1544
|
+
signIns = Object.fromEntries(externalMcp(cfgHome).servers.filter((x) => x.signIn).map((x) => [x.label.replace(/ MCP$/, ""), x.signIn!.startsWith("PAT") ? "PAT" : "az login"]))
|
|
1545
|
+
} catch {}
|
|
1482
1546
|
setupRef.current = {
|
|
1483
1547
|
key: setupKey,
|
|
1484
|
-
items
|
|
1485
|
-
start: startActions(cfgHome),
|
|
1486
|
-
recent
|
|
1548
|
+
items,
|
|
1549
|
+
start: startActions(cfgHome, 4, undefined, recent.some((r) => r.kind === "run")),
|
|
1550
|
+
recent,
|
|
1551
|
+
first: getStarted(cfgHome, items),
|
|
1552
|
+
facts: projectLine(cfgHome, process.cwd(), { model: modelLabel(cfgHome), signIns }),
|
|
1487
1553
|
}
|
|
1488
1554
|
}
|
|
1489
|
-
const { items: setup, start, recent } = setupRef.current
|
|
1555
|
+
const { items: setup, start, recent, first, facts } = setupRef.current
|
|
1490
1556
|
const open = setup.filter((i) => !i.done)
|
|
1491
1557
|
// Each missing item with how to set it up, in the chat; the first that a command sets up (/auth) is offered last.
|
|
1492
1558
|
const explainSetup = (items: SetupItem[]) => push("sys", items.map((i) => `○ ${i.fix}${i.command ? ` Type ${i.command} to start.` : ""}`).join("\n"), "chat")
|
|
1493
|
-
const facts = projectLine(cfgHome, process.cwd(), cfgHome ? modelLabel(cfgHome) : "")
|
|
1494
1559
|
|
|
1495
1560
|
// Rows: the logo, the project line, and the prompt always; then Start with, Continue, and Setup while they fit.
|
|
1496
1561
|
// A long request takes rows from them, last section first, so the box never scrolls while there is room.
|
|
1497
1562
|
const loading = !cfgHome
|
|
1498
1563
|
// Each section is a blank row and then its own rows: a heading and its items, or the setup line.
|
|
1499
|
-
|
|
1564
|
+
// Get started takes Start with's place while there is nothing to start with: no model, or no forgeqe.yaml.
|
|
1565
|
+
const offer: { id: string; label: string; detail: string; run: () => void }[] = start.length
|
|
1566
|
+
? start.map((a) => ({ id: a.id, label: a.label, detail: a.detail, run: () => fillPrompt(a.request) }))
|
|
1567
|
+
: first.map((g) => ({ id: g.id, label: g.label, detail: g.detail, run: () => (g.command ? submitRef.current(g.command) : g.fix && push("sys", g.fix, "chat")) }))
|
|
1568
|
+
const offerTitle = start.length ? "Start with" : "Get started"
|
|
1569
|
+
const startRows = loading ? 1 + 1 + 4 : offer.length ? 1 + 1 + offer.length : 0
|
|
1500
1570
|
const recentRows = recent.length ? 1 + 1 + recent.length : 0
|
|
1501
1571
|
const setupRows = loading || open.length ? 2 : 0
|
|
1502
1572
|
const fixed = 5 + 2 + 1 + 5 + 1 + 1 + 1 // logo, project line, gap, prompt chrome, buttons, footer, one row to spare
|
|
@@ -1521,8 +1591,8 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1521
1591
|
<text>
|
|
1522
1592
|
{facts.length ? (
|
|
1523
1593
|
facts.map((f, i) => (
|
|
1524
|
-
<span key={i} fg={
|
|
1525
|
-
{`${i ? " · " : ""}${f}`}
|
|
1594
|
+
<span key={i} fg={f.tone === "name" ? theme.text : f.tone === "warn" ? theme.warn : theme.muted}>
|
|
1595
|
+
{`${i ? " · " : ""}${f.text}`}
|
|
1526
1596
|
</span>
|
|
1527
1597
|
))
|
|
1528
1598
|
) : (
|
|
@@ -1535,11 +1605,11 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1535
1605
|
{renderControls(promptWidth, "Describe what you want to test…", [5, maxRows])}
|
|
1536
1606
|
{showStart ? (
|
|
1537
1607
|
<box style={{ flexDirection: "column", marginTop: 1, height: startRows - 1, flexShrink: 0 }}>
|
|
1538
|
-
{heading(
|
|
1608
|
+
{heading(offerTitle)}
|
|
1539
1609
|
{loading
|
|
1540
1610
|
? [0, 1, 2, 3].map((i) => <text key={i} fg={theme.faint}>{i ? " " : " Loading what works in this setup…"}</text>)
|
|
1541
|
-
:
|
|
1542
|
-
<box key={a.id} onMouseDown={
|
|
1611
|
+
: offer.map((a, i) => (
|
|
1612
|
+
<box key={a.id} onMouseDown={a.run} style={{ flexDirection: "row", justifyContent: "space-between", flexShrink: 0 }}>
|
|
1543
1613
|
<text>
|
|
1544
1614
|
<span fg={theme.accent}>{" ▸ "}</span>
|
|
1545
1615
|
<span fg={theme.text}>{a.label.padEnd(32)}</span>
|
|
@@ -1640,9 +1710,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1640
1710
|
? coding
|
|
1641
1711
|
? "The coding agent is writing a test · esc to stop"
|
|
1642
1712
|
: `${thinking ? "ForgeQE's agent" : "The browser agent"} is working · esc to stop`
|
|
1643
|
-
:
|
|
1644
|
-
? "Ask ForgeQE · / commands · @ references"
|
|
1645
|
-
: "Ask ForgeQE: a goal, test cases, a test, a pipeline… (/ commands, @ references)"
|
|
1713
|
+
: "Ask ForgeQE… (/ for commands, @ to reference a run, file, or work item)"
|
|
1646
1714
|
const chatLines = lines.filter((l) => l.pane !== "activity")
|
|
1647
1715
|
const activityLines = lines.filter((l) => l.pane === "activity")
|
|
1648
1716
|
const stop = () => openDialog("stop")
|
|
@@ -1684,13 +1752,17 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1684
1752
|
|
|
1685
1753
|
const transcript = (all: Line[], style: Record<string, unknown>) => (
|
|
1686
1754
|
<scrollbox ref={scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1, ...style }} focused={false}>
|
|
1687
|
-
{all.map((line) => (
|
|
1688
|
-
<TranscriptLine key={line.id} line={line} />
|
|
1755
|
+
{all.map((line, i) => (
|
|
1756
|
+
<TranscriptLine key={line.id} line={line} prev={all[i - 1]} />
|
|
1689
1757
|
))}
|
|
1690
|
-
{running &&
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
<
|
|
1758
|
+
{running && !panelsOpen ? (
|
|
1759
|
+
// What is happening now, for how long, and with how many tokens, as one line under the conversation.
|
|
1760
|
+
<box style={{ flexDirection: "row", marginTop: 1, flexShrink: 0 }}>
|
|
1761
|
+
<Spinner kind="dots" color={theme.memory} />
|
|
1762
|
+
<text>
|
|
1763
|
+
<span fg={theme.memory}>{` ${activity || "Working…"}`}</span>
|
|
1764
|
+
<span fg={theme.faint}>{` (${elapsed}${tokens ? ` · ${formatTokens(tokens)} tokens` : ""} · esc to interrupt)`}</span>
|
|
1765
|
+
</text>
|
|
1694
1766
|
</box>
|
|
1695
1767
|
) : null}
|
|
1696
1768
|
</scrollbox>
|
|
@@ -1698,24 +1770,20 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1698
1770
|
// A long request gets room: the box grows with the text up to nearly half the screen, then scrolls.
|
|
1699
1771
|
const controls = (paneWidth: number) => renderControls(paneWidth, placeholder, [3, Math.max(3, Math.floor(height * 0.45))])
|
|
1700
1772
|
|
|
1701
|
-
const split =
|
|
1773
|
+
const split = panelsOpen
|
|
1702
1774
|
if (split) {
|
|
1703
|
-
|
|
1704
|
-
const leftWidth = chatWide ? width : Math.floor(width * 0.4)
|
|
1775
|
+
const leftWidth = Math.floor(width * 0.4)
|
|
1705
1776
|
return (
|
|
1706
1777
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1707
1778
|
<box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
|
|
1708
|
-
<box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight:
|
|
1709
|
-
<SessionPanel info={sessionInfo} width={leftWidth -
|
|
1710
|
-
{
|
|
1711
|
-
{
|
|
1712
|
-
|
|
1779
|
+
<box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
|
|
1780
|
+
<SessionPanel info={sessionInfo} width={leftWidth - 3} />
|
|
1781
|
+
{transcript(chatLines, {})}
|
|
1782
|
+
<box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
|
|
1783
|
+
</box>
|
|
1784
|
+
<box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
|
|
1785
|
+
{details(width - leftWidth - 3, height - 2)}
|
|
1713
1786
|
</box>
|
|
1714
|
-
{chatWide ? null : (
|
|
1715
|
-
<box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
|
|
1716
|
-
{details(width - leftWidth - 3, height - 2)}
|
|
1717
|
-
</box>
|
|
1718
|
-
)}
|
|
1719
1787
|
</box>
|
|
1720
1788
|
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
1721
1789
|
{paletteOverlay}
|
|
@@ -1724,7 +1792,7 @@ export function App({ options }: { options: TuiOptions }) {
|
|
|
1724
1792
|
}
|
|
1725
1793
|
|
|
1726
1794
|
const promptWidth = Math.max(40, width - 4)
|
|
1727
|
-
if (narrowView === "details") {
|
|
1795
|
+
if (!wide && narrowView === "details") {
|
|
1728
1796
|
return (
|
|
1729
1797
|
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
1730
1798
|
<box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}>{details(promptWidth, height - 3 - (dialog ? 8 : 0))}</box>
|