@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.
@@ -122,64 +122,56 @@ export function Prompt(props: PromptProps) {
122
122
  props.onLines?.(needed)
123
123
  setLines(needed)
124
124
  }
125
+ // A rounded box with ">" before the text, as in Claude Code; the border lights up while it has the keys.
125
126
  return (
126
- <box style={{ flexDirection: "column", width: props.width, flexShrink: 0 }}>
127
- <box
128
- border={["left"]}
129
- borderColor={props.modeColor}
130
- customBorderChars={BAR}
131
- style={{
132
- flexDirection: "column",
133
- backgroundColor: theme.panel,
134
- paddingLeft: 2,
135
- paddingRight: 2,
136
- paddingTop: 1,
137
- paddingBottom: 1,
138
- width: props.width,
139
- }}
140
- >
141
- {props.masked !== undefined ? (
142
- <text style={{ height: 1 }}>
143
- {props.masked ? <span fg={theme.text}>{"•".repeat(Math.min(props.masked, Math.max(1, props.width - 8)))}</span> : <span fg={theme.faint}>{props.placeholder}</span>}
144
- </text>
145
- ) : (
146
- <textarea
147
- key={props.inputKey}
148
- ref={ref}
149
- placeholder={props.placeholder}
150
- focused={props.focused !== false}
151
- initialValue={props.initialValue || ""}
152
- wrapMode="word"
153
- keyBindings={PROMPT_KEYS}
154
- onSubmit={() => props.onSubmit(ref.current?.plainText ?? "")}
155
- onContentChange={fit}
156
- style={{ height: rows }}
157
- backgroundColor={theme.panel}
158
- focusedBackgroundColor={theme.panel}
159
- textColor={theme.text}
160
- focusedTextColor={theme.text}
161
- placeholderColor={theme.faint}
162
- />
163
- )}
164
- {props.compact ? null : (
165
- <box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
166
- <text>
167
- <span fg={theme.muted}>{"Model "}</span>
168
- <span fg={theme.text}>{props.model || "…"}</span>
169
- </text>
170
- <text>
171
- {props.right ? <span fg={props.rightColor || theme.muted}>{props.right}</span> : null}
172
- {props.right ? <span fg={theme.faint}>{" · "}</span> : null}
173
- <span fg={theme.muted}>{"profile "}</span>
174
- <span fg={theme.text}>{props.profile || "none"}</span>
127
+ <box
128
+ border
129
+ borderStyle="rounded"
130
+ borderColor={props.focused === false ? theme.border : theme.muted}
131
+ style={{ flexDirection: "column", width: props.width, flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}
132
+ >
133
+ <box style={{ flexDirection: "row" }}>
134
+ <text fg={props.modeColor} style={{ width: 2, flexShrink: 0 }}>
135
+ {">"}
136
+ </text>
137
+ <box style={{ flexDirection: "column", flexGrow: 1, flexShrink: 1 }}>
138
+ {props.masked !== undefined ? (
139
+ <text style={{ height: 1 }}>
140
+ {props.masked ? <span fg={theme.text}>{"•".repeat(Math.min(props.masked, Math.max(1, props.width - 8)))}</span> : <span fg={theme.faint}>{props.placeholder}</span>}
175
141
  </text>
176
- </box>
177
- )}
142
+ ) : (
143
+ <textarea
144
+ key={props.inputKey}
145
+ ref={ref}
146
+ placeholder={props.placeholder}
147
+ focused={props.focused !== false}
148
+ initialValue={props.initialValue || ""}
149
+ wrapMode="word"
150
+ keyBindings={PROMPT_KEYS}
151
+ onSubmit={() => props.onSubmit(ref.current?.plainText ?? "")}
152
+ onContentChange={fit}
153
+ style={{ height: rows }}
154
+ textColor={theme.text}
155
+ focusedTextColor={theme.text}
156
+ placeholderColor={theme.faint}
157
+ />
158
+ )}
159
+ </box>
178
160
  </box>
179
- <text>
180
- <span fg={props.modeColor}>{edge.slice(0, 1)}</span>
181
- <span fg={theme.panel}>{edge.slice(1)}</span>
182
- </text>
161
+ {props.compact ? null : (
162
+ <box style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 }}>
163
+ <text>
164
+ <span fg={theme.muted}>{"Model "}</span>
165
+ <span fg={theme.text}>{props.model || "…"}</span>
166
+ </text>
167
+ <text>
168
+ {props.right ? <span fg={props.rightColor || theme.muted}>{props.right}</span> : null}
169
+ {props.right ? <span fg={theme.faint}>{" · "}</span> : null}
170
+ <span fg={theme.muted}>{"profile "}</span>
171
+ <span fg={theme.text}>{props.profile || "none"}</span>
172
+ </text>
173
+ </box>
174
+ )}
183
175
  </box>
184
176
  )
185
177
  }
@@ -11,32 +11,84 @@ import { allStatuses, configuredSystems, SYSTEM_LABEL, type Probes, MACHINE } fr
11
11
  * in the folder, so it changes as the setup changes.
12
12
  */
13
13
 
14
- /** "C# · .NET solution · Playwright Test": what the folder's own files say it is built with. */
15
- export function projectStack(cwd: string): string[] {
16
- let names: string[] = []
14
+ /** Files a stack is read from, at the top and one or two folders down (tests/Shop.Tests/Shop.Tests.csproj). */
15
+ function projectFiles(cwd: string, match: RegExp, depth = 2): string[] {
16
+ const out: string[] = []
17
+ const walk = (dir: string, level: number) => {
18
+ let names: string[] = []
19
+ try {
20
+ names = readdirSync(dir)
21
+ } catch {
22
+ return
23
+ }
24
+ for (const n of names) {
25
+ if (n.startsWith(".") || n === "node_modules" || n === "bin" || n === "obj") continue
26
+ const full = path.join(dir, n)
27
+ if (match.test(n)) out.push(full)
28
+ else if (level < depth) {
29
+ try {
30
+ if (statSync(full).isDirectory()) walk(full, level + 1)
31
+ } catch {}
32
+ }
33
+ if (out.length > 20) return
34
+ }
35
+ }
36
+ walk(cwd, 0)
37
+ return out
38
+ }
39
+
40
+ const read = (file: string) => {
17
41
  try {
18
- names = readdirSync(cwd)
42
+ return readFileSync(file, "utf8")
19
43
  } catch {
20
- return []
44
+ return ""
21
45
  }
22
- const has = (re: RegExp) => names.some((n) => re.test(n))
23
- const out: string[] = []
24
- if (has(/^playwright\.config\.(ts|js|mjs|cjs)$/)) out.push("Playwright Test")
25
- if (has(/^cypress\.config\.(ts|js|mjs|cjs)$/)) out.push("Cypress")
26
- if (has(/\.sln$/) || has(/\.csproj$/)) out.push(".NET")
27
- if (has(/^pom\.xml$/)) out.push("Maven")
28
- if (has(/^build\.gradle(\.kts)?$/)) out.push("Gradle")
29
- if (has(/^(pytest\.ini|conftest\.py)$/) || (has(/^pyproject\.toml$/) && !out.length)) out.push("Python")
30
- if (has(/^package\.json$/) && !out.some((s) => s === "Playwright Test" || s === "Cypress")) out.push("Node")
31
- return out.slice(0, 2)
32
46
  }
33
47
 
34
- /** The project line: folder, stack, the systems ForgeQE works with here, and the model. */
35
- export function projectLine(cfg: AppConfig | null, cwd: string, model: string): string[] {
36
- const parts = [path.basename(cwd), ...projectStack(cwd)]
37
- if (cfg) parts.push(...[...configuredSystems(cfg)].filter((s) => s !== "xray" || !configuredSystems(cfg).has("jira")).map((s) => SYSTEM_LABEL[s]))
38
- if (model) parts.push(model)
39
- return parts.filter(Boolean)
48
+ /**
49
+ * "C# · NUnit · Playwright": the language, test framework, and browser library the project's own files declare
50
+ * (project files, package.json, Python requirements). Read from the files, never guessed from folder names.
51
+ */
52
+ export function projectStack(cwd: string): string[] {
53
+ const csproj = projectFiles(cwd, /\.csproj$/).map(read).join("\n")
54
+ if (csproj) {
55
+ const framework = /PackageReference Include="NUnit"/i.test(csproj) ? "NUnit" : /PackageReference Include="xunit/i.test(csproj) ? "xUnit" : /PackageReference Include="MSTest/i.test(csproj) ? "MSTest" : ""
56
+ const browser = /Microsoft\.Playwright/i.test(csproj) ? "Playwright" : /Selenium\.WebDriver/i.test(csproj) ? "Selenium" : /Reqnroll|SpecFlow/i.test(csproj) ? "BDD" : ""
57
+ return ["C#", framework, browser].filter(Boolean)
58
+ }
59
+ const pkg = read(path.join(cwd, "package.json"))
60
+ if (pkg) {
61
+ const lang = existsSync(path.join(cwd, "tsconfig.json")) ? "TypeScript" : "JavaScript"
62
+ const runner = /"@playwright\/test"/.test(pkg) ? "Playwright Test" : /"cypress"/.test(pkg) ? "Cypress" : /"webdriverio"|"@wdio\//.test(pkg) ? "WebdriverIO" : /"vitest"/.test(pkg) ? "Vitest" : /"jest"/.test(pkg) ? "Jest" : ""
63
+ return [lang, runner].filter(Boolean)
64
+ }
65
+ const py = ["requirements.txt", "pyproject.toml", "setup.cfg"].map((f) => read(path.join(cwd, f))).join("\n")
66
+ if (py.trim() || existsSync(path.join(cwd, "pytest.ini")) || existsSync(path.join(cwd, "conftest.py"))) {
67
+ return ["Python", /pytest/i.test(py) || existsSync(path.join(cwd, "pytest.ini")) ? "pytest" : "", /playwright/i.test(py) ? "Playwright" : /selenium/i.test(py) ? "Selenium" : ""].filter(Boolean)
68
+ }
69
+ const java = [read(path.join(cwd, "pom.xml")), read(path.join(cwd, "build.gradle")), read(path.join(cwd, "build.gradle.kts"))].join("\n")
70
+ if (java.trim()) return ["Java", /junit-jupiter|junit/i.test(java) ? "JUnit" : /testng/i.test(java) ? "TestNG" : "", /playwright/i.test(java) ? "Playwright" : /selenium/i.test(java) ? "Selenium" : ""].filter(Boolean)
71
+ return []
72
+ }
73
+
74
+ export type Fact = { text: string; tone: "name" | "plain" | "warn" }
75
+
76
+ /**
77
+ * The project line: the folder, its stack, each system ForgeQE works with here and how it is signed in ("Azure DevOps
78
+ * (PAT)"), and the model; or, where nothing is set up, that this folder has no forgeqe.yaml.
79
+ */
80
+ export function projectLine(cfg: AppConfig | null, cwd: string, opts: { model: string; signIns?: Record<string, string> }): Fact[] {
81
+ const facts: Fact[] = [{ text: path.basename(cwd), tone: "name" }, ...projectStack(cwd).map((t) => ({ text: t, tone: "plain" as const }))]
82
+ if (!cfg) return facts
83
+ if (!cfg.configPath) return [...facts, { text: "no forgeqe.yaml here", tone: "warn" }]
84
+ const systems = [...configuredSystems(cfg)].filter((s) => s !== "xray" || !configuredSystems(cfg).has("jira"))
85
+ for (const s of systems) {
86
+ const how = opts.signIns?.[SYSTEM_LABEL[s]]
87
+ facts.push({ text: how ? `${SYSTEM_LABEL[s]} (${how})` : SYSTEM_LABEL[s], tone: "plain" })
88
+ }
89
+ const live = cfg.llm.provider !== "none" && cfg.llm.provider !== "mock"
90
+ facts.push(live && opts.model ? { text: opts.model, tone: "plain" } : { text: "no model", tone: "warn" })
91
+ return facts
40
92
  }
41
93
 
42
94
  const ALL_SYSTEMS = Object.values(SYSTEM_LABEL)
@@ -61,8 +113,9 @@ const START_LABEL: Record<string, string> = {
61
113
  "browser-goals": "Try a flow in the browser",
62
114
  }
63
115
 
64
- export function startActions(cfg: AppConfig | null, limit = 4, probes: Probes = MACHINE): StartAction[] {
65
- if (!cfg) return []
116
+ export function startActions(cfg: AppConfig | null, limit = 4, probes: Probes = MACHINE, hasRuns = true): StartAction[] {
117
+ // Without a live model nothing on the list can run: the start screen offers Get started instead.
118
+ if (!cfg || cfg.llm.provider === "none" || cfg.llm.provider === "mock") return []
66
119
  const statuses = allStatuses(cfg, probes)
67
120
  const systems = [...configuredSystems(cfg)].map((x) => SYSTEM_LABEL[x])
68
121
  const out: StartAction[] = []
@@ -70,9 +123,12 @@ export function startActions(cfg: AppConfig | null, limit = 4, probes: Probes =
70
123
  const c = CAPABILITIES.find((x) => x.id === id)
71
124
  if (!c || statuses.get(id)?.state === "needs_setup") continue
72
125
  // An example in the words of this setup: one that names a system that is set up, else one that names none.
73
- const named = c.examples.find((e) => systems.some((n) => e.includes(n)))
74
- const neutral = c.examples.find((e) => !ALL_SYSTEMS.some((n) => e.includes(n)))
75
- const example = named || neutral || c.examples[0] || c.title
126
+ // Not an example that builds on a run when there is none to build on.
127
+ const usable = c.examples.filter((e) => hasRuns || !/\blast run\b|\bthe run\b|that run/i.test(e))
128
+ if (!usable.length) continue
129
+ const named = usable.find((e) => systems.some((n) => e.includes(n)))
130
+ const neutral = usable.find((e) => !ALL_SYSTEMS.some((n) => e.includes(n)))
131
+ const example = named || neutral || usable[0]!
76
132
  out.push({ id, label: START_LABEL[id] || c.title, detail: example, request: example })
77
133
  if (out.length === limit) break
78
134
  }
@@ -91,9 +147,11 @@ export function recentWork(outDir: string, limit = 3): RecentItem[] {
91
147
  if (!statSync(dir).isDirectory()) continue
92
148
  const when = statSync(dir).mtime
93
149
  if (existsSync(path.join(dir, "run.json"))) {
94
- const run = JSON.parse(readFileSync(path.join(dir, "run.json"), "utf8")) as { goal?: string; status?: string }
150
+ // The engine writes success; ForgeQE's own records write status. Either says how the run ended.
151
+ const run = JSON.parse(readFileSync(path.join(dir, "run.json"), "utf8")) as { goal?: string; status?: string; success?: boolean }
95
152
  const report = path.join(dir, "report.html")
96
- items.push({ kind: "run", title: String(run.goal || name), dir, open: existsSync(report) ? report : dir, when, status: run.status })
153
+ const status = run.status || (run.success === true ? "passed" : run.success === false ? "fail" : undefined)
154
+ items.push({ kind: "run", title: String(run.goal || name), dir, open: existsSync(report) ? report : dir, when, ...(status ? { status } : {}) })
97
155
  } else if (existsSync(path.join(dir, "report.md"))) {
98
156
  const first = readFileSync(path.join(dir, "report.md"), "utf8").split("\n")[0] || ""
99
157
  const html = path.join(dir, "report.html")
@@ -126,3 +184,18 @@ export function setupSentence(missing: string[], room: number): string {
126
184
  const named = `${listWords(missing)} ${missing.length === 1 ? "is" : "are"} not set up`
127
185
  return named.length <= room ? named : `${missing.length} ${missing.length === 1 ? "item is" : "items are"} not set up`
128
186
  }
187
+
188
+ /** What to do first where little is set up: each step runs a command or says how, in the order they are needed. */
189
+ export type GetStarted = { id: string; label: string; detail: string; command?: string; fix?: string }
190
+
191
+ export function getStarted(cfg: AppConfig | null, setup: { id: string; done: boolean; fix?: string; command?: string }[]): GetStarted[] {
192
+ if (!cfg) return []
193
+ const out: GetStarted[] = []
194
+ if (!cfg.configPath) out.push({ id: "init", label: "Set up ForgeQE in this folder", detail: "writes forgeqe.yaml, llms.json, prompts, skills", command: "/init" })
195
+ const missing = (id: string) => setup.find((i) => i.id === id && !i.done)
196
+ const model = missing("model")
197
+ if (model) out.push({ id: "model", label: "Add a model", detail: "an API key or Microsoft Entra ID, in llms.json", fix: model.fix })
198
+ const system = missing("system")
199
+ if (system) out.push({ id: "system", label: "Sign in to Azure DevOps or Jira", detail: "test cases, stories, pipelines", command: "/auth" })
200
+ return out.slice(0, 4)
201
+ }
package/src/tui/next.ts CHANGED
@@ -26,7 +26,7 @@ export function nextSteps(p: Produced, opts: { testsOn: boolean; followUps?: Fol
26
26
  steps.push({ label: "Rerun the cases that failed", run: { kind: "insert", text: "Run the cases that failed in the last suite again" } })
27
27
  } else if (run) {
28
28
  const report = path.join(run.runDir, "report.html")
29
- if (run.status === "complete" && !p.tests.some((t) => t.runDir === run.runDir)) steps.push({ label: "Write a test for this run", run: { kind: "insert", text: "Write a test for the last run" } })
29
+ if (opts.testsOn && run.status === "complete" && !p.tests.some((t) => t.runDir === run.runDir)) steps.push({ label: "Write a test for this run", run: { kind: "insert", text: "Write a test for the last run" } })
30
30
  if (run.status !== "complete") {
31
31
  steps.push({ label: "Why did it fail?", run: { kind: "insert", text: "Why did the last run fail, and what should change?" } })
32
32
  steps.push({ label: "Run it again, visible", run: { kind: "insert", text: "Run the last goal again in a visible browser" } })
@@ -34,6 +34,7 @@ export function paletteItems(opts: { running: boolean; headed: boolean; detailsF
34
34
  items.push({ id: "shortcuts", group: "Action", title: "Keyboard shortcuts", keys: "?", run: { kind: "action", id: "shortcuts" } })
35
35
  command("features", "What ForgeQE can do here", "/features", "and what each feature still needs")
36
36
  command("auth", "Sign in to a system", "/auth", "Azure DevOps, GitHub, Jira, Confluence, TestRail, Zephyr, qTest, Xray")
37
+ command("init", "Set up ForgeQE in this folder", "/init", "forgeqe.yaml, llms.json, prompts, skills, knowledge")
37
38
  command("auth-status", "What is signed in", "/auth status")
38
39
  command("headed", opts.headed ? "Hide the browser window" : "Show the browser window", `/headed ${opts.headed ? "off" : "on"}`)
39
40
  command("details", opts.detailsFull ? "Show progress only" : "Show everything the agents do", `/details ${opts.detailsFull ? "off" : "on"}`)
@@ -1,11 +1,12 @@
1
1
  import type { Refined } from "../refine.ts"
2
2
  import { Spinner } from "./components.tsx"
3
- import { theme } from "./theme.ts"
3
+ import { formatDuration, formatTokens, theme } from "./theme.ts"
4
4
 
5
5
  /** Refine's panel, in the prompt's place: working, the result with what was checked, or why it could not refine. */
6
6
  export type RefineState =
7
7
  | { phase: "working"; original: string }
8
- | { phase: "done"; original: string; result: Refined; choice: number }
8
+ /** `tokens` and `ms`: what Refine's own model session cost, shown in the title. */
9
+ | { phase: "done"; original: string; result: Refined; choice: number; tokens?: number; ms?: number }
9
10
  | { phase: "error"; original: string; message: string }
10
11
 
11
12
  export const REFINE_CHOICES = [
@@ -18,9 +19,24 @@ export type RefineChoice = (typeof REFINE_CHOICES)[number]["id"]
18
19
 
19
20
  const COLOR = theme.memory
20
21
 
22
+ /** A fact as written in context and in grounded, compared without case, spaces, or a closing full stop. */
23
+ const norm = (s: string) => s.trim().replace(/\.$/, "").replace(/\s+/g, " ").toLowerCase()
24
+
25
+ /** The choice the panel starts on: Edit while Refine still has a question, so an unclear request is not sent. */
26
+ export function initialChoice(missing: string[]): number {
27
+ return REFINE_CHOICES.findIndex((c) => c.id === (missing.length ? "edit" : "use"))
28
+ }
29
+
21
30
  export function RefinePanel(props: { state: RefineState; width: number; onChoose: (id: RefineChoice) => void; onHover: (i: number) => void }) {
22
31
  const { state, width } = props
23
- const title = state.phase === "working" ? " ✦ Refining " : state.phase === "error" ? " ✦ Could not refine " : " ✦ Refined request "
32
+ const cost = state.phase === "done" && state.tokens ? ` · ${formatTokens(state.tokens)} tokens${state.ms ? ` · ${formatDuration(state.ms)}` : ""}` : ""
33
+ // How much of the request keeps the ASD-STE100 rules, as Refine's prompt asks.
34
+ const ste = state.phase === "done" && state.result.ste !== undefined ? ` · STE ${Math.round(state.result.ste * 100)}%` : ""
35
+ const title = state.phase === "working" ? " ✦ Refining " : state.phase === "error" ? " ✦ Could not refine " : ` ✦ Refined request${ste}${cost} `
36
+ // Facts Refine checked that are also context lines are marked there, not repeated under Checked.
37
+ const contextLines = state.phase === "done" ? state.result.refined.split("\n").filter((l) => l.startsWith("- ")).map((l) => norm(l.slice(2))) : []
38
+ const inContext = new Set(contextLines)
39
+ const checked = new Set(state.phase === "done" ? state.result.grounded.filter((g) => g.checked).map((g) => norm(g.fact)).filter((f) => inContext.has(f)) : [])
24
40
  const section = (label: string, color: string, mark: string, items: string[]) =>
25
41
  items.length ? (
26
42
  <box style={{ flexDirection: "column", marginTop: 1 }}>
@@ -49,9 +65,18 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
49
65
  <text fg={theme.err}>{state.message}</text>
50
66
  ) : (
51
67
  <>
52
- {/* The request as it is sent: its labelled parts (Goal, Context, Steps, Rules, Result) stand out. */}
68
+ {/* The request as it is sent: its labelled parts (Role, Objective, Context, Steps, Rules, Output format, Summary) stand out. */}
53
69
  {state.result.refined.split("\n").map((line, i) => {
54
- const label = /^(Goal|Context|Steps|Rules|Result):/.exec(line)
70
+ const label = /^(Role|Objective|Context|Steps|Rules|Output format|Summary|Goal|Result):/.exec(line)
71
+ // A context line Refine checked against the systems: ✓ in place of its dash, so it is not listed twice.
72
+ if (line.startsWith("- ") && checked.has(norm(line.slice(2)))) {
73
+ return (
74
+ <text key={i}>
75
+ <span fg={theme.ok}>{"✓ "}</span>
76
+ <span fg={theme.text}>{line.slice(2)}</span>
77
+ </text>
78
+ )
79
+ }
55
80
  return label ? (
56
81
  <text key={i}>
57
82
  <span fg={COLOR}>{label[0]}</span>
@@ -63,7 +88,7 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
63
88
  </text>
64
89
  )
65
90
  })}
66
- {section("Checked", theme.ok, "✓", state.result.grounded.filter((g) => g.checked).map((g) => g.fact))}
91
+ {section("Checked", theme.ok, "✓", state.result.grounded.filter((g) => g.checked && !inContext.has(norm(g.fact))).map((g) => g.fact))}
67
92
  {section("Assumed", theme.warn, "~", [...state.result.grounded.filter((g) => !g.checked).map((g) => g.fact), ...state.result.assumed])}
68
93
  {section("Missing — answer these in your request", theme.err, "?", state.result.missing)}
69
94
  </>
@@ -88,7 +113,7 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
88
113
  {state.phase === "done" ? (
89
114
  <>
90
115
  <span fg={theme.text}>{"enter"}</span>
91
- <span fg={theme.muted}>{" use "}</span>
116
+ <span fg={theme.muted}>{` ${REFINE_CHOICES[state.choice]!.id === "keep" ? "keep mine" : REFINE_CHOICES[state.choice]!.id} `}</span>
92
117
  <span fg={theme.text}>{"e"}</span>
93
118
  <span fg={theme.muted}>{" edit "}</span>
94
119
  <span fg={theme.text}>{"esc"}</span>
@@ -17,7 +17,7 @@ export const SHORTCUTS: { group: string; keys: [string, string][] }[] = [
17
17
  keys: [
18
18
  ["ctrl+p", "search: actions, commands, profiles, examples"],
19
19
  ["shift+tab", "move between the chat and the plan, activity, and files panels"],
20
- ["ctrl+o", "chat or details, on a narrow terminal"],
20
+ ["ctrl+o", "open or close the panels: plan, activity, files, context, tokens"],
21
21
  ["alt+1 to alt+4", "a start action, or a next step"],
22
22
  ],
23
23
  },