footer.view.test.tsx 58 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974
  1. /** @jsxImportSource @opentui/solid */
  2. import { expect, test } from "bun:test"
  3. import { BoxRenderable, RGBA, type RootRenderable } from "@opentui/core"
  4. import { testRender } from "@opentui/solid"
  5. import { createSignal } from "solid-js"
  6. import type { FormInfo } from "@opencode-ai/client/promise"
  7. import { Keymap } from "../../src/context/keymap"
  8. import {
  9. RUN_COMMAND_PANEL_ROWS,
  10. RUN_SUBAGENT_PANEL_ROWS,
  11. RunAgentSelectBody,
  12. RunCommandMenuBody,
  13. RunModelSelectBody,
  14. RunQueuedPromptSelectBody,
  15. RunSettingsBody,
  16. RunSkillSelectBody,
  17. RunSubagentSelectBody,
  18. RunVariantSelectBody,
  19. } from "../../src/mini/footer.command"
  20. import { RunFooterView } from "../../src/mini/footer.view"
  21. import { RunEntryContent } from "../../src/mini/scrollback.writer"
  22. import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
  23. import type {
  24. FooterQueuedPrompt,
  25. FooterState,
  26. FooterSubagentState,
  27. FooterSubagentTab,
  28. FooterView,
  29. MiniSettingChange,
  30. MiniSettings,
  31. RunAgent,
  32. RunCommand,
  33. RunInput,
  34. RunPrompt,
  35. RunProvider,
  36. RunTuiConfig,
  37. StreamCommit,
  38. } from "../../src/mini/types"
  39. import { selectedCommand } from "../../src/mini/footer.prompt"
  40. import { RejectField } from "../../src/mini/footer.permission"
  41. import { createTuiResolvedConfig } from "../fixture/tui-runtime"
  42. const tuiConfig = createTuiResolvedConfig()
  43. function command(input: { name: string; description: string; source?: "command" | "mcp" | "skill" }) {
  44. return {
  45. name: input.name,
  46. description: input.description,
  47. source: input.source,
  48. } satisfies RunCommand
  49. }
  50. function model(input: {
  51. id: string
  52. name: string
  53. status?: "active" | "deprecated"
  54. cost?: number
  55. variants?: Record<string, Record<string, never>>
  56. }) {
  57. return {
  58. name: input.name,
  59. cost: {
  60. input: input.cost ?? 1,
  61. },
  62. status: input.status ?? "active",
  63. variants: input.variants,
  64. } satisfies RunProvider["models"][string]
  65. }
  66. function provider() {
  67. return {
  68. id: "opencode",
  69. name: "opencode",
  70. models: {
  71. "gpt-5": model({ id: "gpt-5", name: "GPT-5", variants: { high: {}, minimal: {} } }),
  72. "gpt-free": model({ id: "gpt-free", name: "GPT Free", cost: 0 }),
  73. old: model({ id: "old", name: "Old Model", status: "deprecated" }),
  74. },
  75. } satisfies RunProvider
  76. }
  77. function subagent(input: {
  78. sessionID: string
  79. label: string
  80. description: string
  81. status?: FooterSubagentTab["status"]
  82. }) {
  83. return {
  84. sessionID: input.sessionID,
  85. label: input.label,
  86. description: input.description,
  87. status: input.status ?? "running",
  88. } satisfies FooterSubagentTab
  89. }
  90. function footerState(input: Partial<FooterState> = {}) {
  91. return createSignal<FooterState>({
  92. phase: "idle",
  93. status: "",
  94. notice: "",
  95. model: "gpt-5",
  96. usage: "",
  97. first: false,
  98. interrupt: 0,
  99. exit: 0,
  100. ...input,
  101. })
  102. }
  103. async function renderFooter(
  104. input: {
  105. tuiConfig?: RunTuiConfig
  106. commands?: RunCommand[]
  107. theme?: () => RunTheme
  108. providers?: RunProvider[]
  109. currentAgent?: string
  110. currentModel?: RunInput["model"]
  111. currentVariant?: string
  112. subagents?: FooterSubagentState
  113. width?: number
  114. height?: number
  115. state?: Partial<FooterState>
  116. onCycle?: () => void
  117. onSubmit?: (prompt: RunPrompt) => boolean | Promise<boolean>
  118. view?: FooterView
  119. onFormReply?: (input: unknown) => void
  120. miniSettings?: MiniSettings
  121. mono?: boolean
  122. onStatus?: (status: string) => void
  123. onMiniSettingChange?: (change: MiniSettingChange) => void
  124. queuedPrompts?: FooterQueuedPrompt[]
  125. onQueuedPromptAction?: (action: "steer" | "cancel", inboxID: string) => Promise<void>
  126. } = {},
  127. ) {
  128. const [view, setView] = createSignal<FooterView>(input.view ?? { type: "prompt" })
  129. const [subagents] = createSignal<FooterSubagentState>(
  130. input.subagents ?? { tabs: [], details: {}, permissions: [], forms: [] },
  131. )
  132. const [state, setState] = footerState(input.state)
  133. const config = input.tuiConfig ?? tuiConfig
  134. const [miniSettings] = createSignal<MiniSettings>(
  135. input.miniSettings ?? {
  136. thinking: "hide",
  137. shell_output: "hide",
  138. turn_summary: "show",
  139. footer: "show",
  140. splash: "show",
  141. mono: false,
  142. },
  143. )
  144. function Harness() {
  145. return (
  146. <Keymap.Provider config={config}>
  147. <RunFooterView
  148. directory={() => "/tmp"}
  149. findFiles={async () => []}
  150. agents={() => []}
  151. references={() => []}
  152. commands={() => input.commands ?? []}
  153. providers={() => input.providers}
  154. currentAgent={() => input.currentAgent ?? "Build"}
  155. currentAgentID={() => input.currentAgent?.toLowerCase() ?? "build"}
  156. currentAgentExplicit={() => input.currentAgent !== undefined}
  157. currentModel={() => input.currentModel}
  158. variants={() => []}
  159. currentVariant={() => input.currentVariant}
  160. state={state}
  161. view={view}
  162. subagent={subagents}
  163. queuedPrompts={() => input.queuedPrompts ?? []}
  164. theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
  165. mono={input.mono ?? false}
  166. miniSettings={miniSettings}
  167. onSubmit={input.onSubmit ?? (() => true)}
  168. onPermissionReply={() => {}}
  169. onFormReply={(value) => input.onFormReply?.(value)}
  170. onFormCancel={() => {}}
  171. onCycle={input.onCycle ?? (() => {})}
  172. onInterrupt={() => false}
  173. onQueuedPromptAction={input.onQueuedPromptAction}
  174. onEditorOpen={async () => undefined}
  175. onInputClear={() => {}}
  176. onExit={() => {}}
  177. onAgentSelect={() => {}}
  178. onModelSelect={() => {}}
  179. onVariantSelect={() => {}}
  180. onRows={() => {}}
  181. onLayout={() => {}}
  182. onStatus={(status) => input.onStatus?.(status)}
  183. onMiniSettingChange={(change) => input.onMiniSettingChange?.(change)}
  184. />
  185. </Keymap.Provider>
  186. )
  187. }
  188. const app = await testRender(
  189. () => (
  190. <box width={input.width ?? 100} height={input.height ?? 8}>
  191. <Harness />
  192. </box>
  193. ),
  194. { width: input.width ?? 100, height: input.height ?? 8, kittyKeyboard: true },
  195. )
  196. return {
  197. ...app,
  198. setView,
  199. setState,
  200. cleanup() {
  201. app.renderer.currentFocusedRenderable?.blur()
  202. app.renderer.currentFocusedEditor?.blur()
  203. app.renderer.destroy()
  204. },
  205. }
  206. }
  207. test("direct footer shows the default model without the fallback agent", async () => {
  208. const app = await renderFooter({ state: { model: "Default model" } })
  209. try {
  210. await app.renderOnce()
  211. const frame = app.captureCharFrame()
  212. expect(frame).toContain("Default model")
  213. expect(frame).not.toContain("Build")
  214. } finally {
  215. app.cleanup()
  216. }
  217. })
  218. test("direct footer preserves a partial multi-field form draft across permission preemption", async () => {
  219. const request: FormInfo = {
  220. id: "frm_preempted",
  221. sessionID: "ses_child",
  222. title: "Deployment",
  223. fields: [
  224. { key: "service", type: "string", title: "Service", required: true },
  225. { key: "notes", type: "string", title: "Notes", required: true },
  226. ],
  227. }
  228. const app = await renderFooter({
  229. height: 16,
  230. view: { type: "form", request },
  231. })
  232. try {
  233. await app.renderOnce()
  234. "api".split("").forEach((key) => app.mockInput.pressKey(key))
  235. app.mockInput.pressEnter()
  236. await app.renderOnce()
  237. "keep this draft".split("").forEach((key) => app.mockInput.pressKey(key))
  238. expect(app.renderer.currentFocusedEditor?.plainText).toBe("keep this draft")
  239. app.setView({
  240. type: "permission",
  241. request: {
  242. id: "per_preempting",
  243. sessionID: "ses_child",
  244. action: "read",
  245. resources: ["src/index.ts"],
  246. },
  247. })
  248. await app.renderOnce()
  249. expect(app.captureCharFrame()).toContain("Permission required")
  250. app.setView({ type: "form", request })
  251. await app.renderOnce()
  252. expect(app.captureCharFrame()).toContain("2/2")
  253. expect(app.renderer.currentFocusedEditor?.plainText).toBe("keep this draft")
  254. app.setView({ type: "prompt" })
  255. await app.renderOnce()
  256. app.setView({ type: "form", request })
  257. await app.renderOnce()
  258. expect(app.captureCharFrame()).toContain("1/2")
  259. expect(app.renderer.currentFocusedEditor?.plainText).toBe("")
  260. } finally {
  261. app.cleanup()
  262. }
  263. })
  264. function expectPaletteList(list: BoxRenderable, selectedIndex: number) {
  265. expect(list.backgroundColor.toInts()).toEqual((RUN_THEME_FALLBACK.footer.shade as RGBA).toInts())
  266. expect((list.getChildren()[selectedIndex] as BoxRenderable).backgroundColor.toInts()).toEqual(
  267. (RUN_THEME_FALLBACK.footer.selected as RGBA).toInts(),
  268. )
  269. }
  270. function child(root: BoxRenderable | RootRenderable, index: number) {
  271. return root.getChildren()[index] as BoxRenderable
  272. }
  273. function boxPath(root: BoxRenderable | RootRenderable, name: string): BoxRenderable[] | undefined {
  274. for (const item of root.getChildren()) {
  275. if (item.constructor.name === name) return root instanceof BoxRenderable ? [root] : []
  276. if (!(item instanceof BoxRenderable)) continue
  277. const path = boxPath(item, name)
  278. if (path) return root instanceof BoxRenderable ? [root, ...path] : path
  279. }
  280. }
  281. function footerComposerFrame(root: BoxRenderable | RootRenderable) {
  282. return boxPath(root, "TextareaRenderable")!.at(-5)!
  283. }
  284. function footerStatusline(root: BoxRenderable | RootRenderable) {
  285. const status = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
  286. const boxes = root.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable)
  287. for (const box of boxes) {
  288. if (box.backgroundColor?.toInts().every((value, index) => value === status[index])) return box
  289. boxes.push(...box.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable))
  290. }
  291. throw new Error("Footer statusline not found")
  292. }
  293. function panelMenu(root: BoxRenderable | RootRenderable) {
  294. const panel = child(child(root, 0), 0)
  295. const content = child(panel, 0)
  296. return child(content.getChildren().at(-1) as BoxRenderable, 0)
  297. }
  298. test("direct footer composer area does not adopt footer surface", async () => {
  299. const surface = RGBA.fromHex("#123456")
  300. const [theme, setTheme] = createSignal(RUN_THEME_FALLBACK)
  301. const app = await renderFooter({ theme })
  302. try {
  303. await app.renderOnce()
  304. const area = child(footerComposerFrame(app.renderer.root), 0)
  305. expect(area.backgroundColor.toInts()).not.toEqual(surface.toInts())
  306. setTheme({
  307. ...RUN_THEME_FALLBACK,
  308. footer: {
  309. ...RUN_THEME_FALLBACK.footer,
  310. surface,
  311. },
  312. })
  313. await app.renderOnce()
  314. expect(area.backgroundColor.toInts()).not.toEqual(surface.toInts())
  315. } finally {
  316. app.cleanup()
  317. }
  318. })
  319. test("run entry content updates when live commit text changes", async () => {
  320. const [commit, setCommit] = createSignal<StreamCommit>({
  321. kind: "tool",
  322. text: "I",
  323. phase: "progress",
  324. source: "tool",
  325. messageID: "msg-1",
  326. partID: "part-1",
  327. tool: "shell",
  328. })
  329. const app = await testRender(
  330. () => (
  331. <box width={80} height={4}>
  332. <RunEntryContent commit={commit()} theme={RUN_THEME_FALLBACK} />
  333. </box>
  334. ),
  335. {
  336. width: 80,
  337. height: 4,
  338. },
  339. )
  340. try {
  341. await app.renderOnce()
  342. expect(app.captureCharFrame()).toContain("I")
  343. setCommit({
  344. kind: "tool",
  345. text: "I need to inspect the codebase",
  346. phase: "progress",
  347. source: "tool",
  348. messageID: "msg-1",
  349. partID: "part-1",
  350. tool: "shell",
  351. })
  352. await app.renderOnce()
  353. expect(app.captureCharFrame()).toContain("I need to inspect the codebase")
  354. } finally {
  355. app.renderer.destroy()
  356. }
  357. })
  358. test("run entry content preserves monochrome markdown grammar", async () => {
  359. const [commit, setCommit] = createSignal<StreamCommit>({
  360. kind: "assistant",
  361. text: "• literal\n\n———\n\narrow →",
  362. phase: "progress",
  363. source: "assistant",
  364. messageID: "msg-1",
  365. partID: "part-1",
  366. })
  367. const app = await testRender(
  368. () => (
  369. <box width={60} height={8}>
  370. <RunEntryContent commit={commit()} theme={RUN_THEME_FALLBACK} opts={{ mono: true }} />
  371. </box>
  372. ),
  373. { width: 60, height: 8 },
  374. )
  375. try {
  376. await app.renderOnce()
  377. const rows = app
  378. .captureCharFrame()
  379. .split("\n")
  380. .map((row) => row.trimEnd())
  381. expect(rows).toContain("* literal")
  382. expect(rows).toContain("------")
  383. expect(rows).toContain("arrow ->")
  384. expect(rows.join("\n")).not.toMatch(/[^\x00-\x7f]/)
  385. setCommit({ ...commit(), text: "- Café\n- arrow →\n- third …" })
  386. await app.renderOnce()
  387. expect(app.captureCharFrame()).toContain("- arrow ->")
  388. expect(app.captureCharFrame()).toContain("- third ...")
  389. setCommit({ ...commit(), text: "| A | B |\n| - | - |\n| Café | → |" })
  390. await app.renderOnce()
  391. expect(app.captureCharFrame()).toContain("Caf?")
  392. expect(app.captureCharFrame()).toContain("->")
  393. setCommit({ ...commit(), text: "| A | B |\n| - | - |\n| Café | … |" })
  394. await app.renderOnce()
  395. expect(app.captureCharFrame()).toContain("...")
  396. setCommit({ ...commit(), text: "```\nCafé → …\n```" })
  397. await app.renderOnce()
  398. expect(app.captureCharFrame()).toContain("Caf? -> ...")
  399. expect(app.captureCharFrame()).not.toMatch(/[^\x00-\x7f]/)
  400. } finally {
  401. app.renderer.destroy()
  402. }
  403. })
  404. test("run entry content eagerly renders final monochrome markdown", async () => {
  405. const app = await testRender(
  406. () => (
  407. <box width={60} height={6}>
  408. <RunEntryContent
  409. commit={{ kind: "tool", text: "", phase: "final", source: "tool", tool: "subagent" }}
  410. body={{ type: "markdown", content: "# Café →\n\n```markdown\nCafé →\n```" }}
  411. theme={RUN_THEME_FALLBACK}
  412. opts={{ mono: true }}
  413. />
  414. </box>
  415. ),
  416. { width: 60, height: 6 },
  417. )
  418. try {
  419. await app.renderOnce()
  420. const frame = app.captureCharFrame()
  421. expect(frame).toContain("# Caf? ->")
  422. expect(frame).toContain("Caf? ->")
  423. expect(frame).not.toMatch(/[^\x00-\x7f]/)
  424. } finally {
  425. app.renderer.destroy()
  426. }
  427. })
  428. test("direct command panel renders grouped actions without catalog commands", async () => {
  429. const [commands] = createSignal<RunCommand[] | undefined>([
  430. command({ name: "review", description: "Review code" }),
  431. command({ name: "deploy", description: "Deploy prompt", source: "mcp" }),
  432. command({ name: "internal", description: "Skill command", source: "skill" }),
  433. ])
  434. const [subagents] = createSignal([])
  435. const [variants] = createSignal(["high", "minimal"])
  436. let status = 0
  437. const app = await testRender(
  438. () => (
  439. <box width={100} height={RUN_COMMAND_PANEL_ROWS}>
  440. <RunCommandMenuBody
  441. theme={() => RUN_THEME_FALLBACK.footer}
  442. commands={commands}
  443. subagents={subagents}
  444. queued={() => []}
  445. variants={variants}
  446. variantCycle="ctrl+t"
  447. onClose={() => {}}
  448. onAgent={() => {}}
  449. onModel={() => {}}
  450. onEditor={() => {}}
  451. onSkill={() => {}}
  452. onSubagent={() => {}}
  453. onQueued={() => {}}
  454. onVariant={() => {}}
  455. onVariantCycle={() => {}}
  456. onStatus={() => status++}
  457. onSettings={() => {}}
  458. onCommand={() => {}}
  459. onNew={() => {}}
  460. onExit={() => {}}
  461. />
  462. </box>
  463. ),
  464. {
  465. width: 100,
  466. height: RUN_COMMAND_PANEL_ROWS,
  467. },
  468. )
  469. try {
  470. await app.renderOnce()
  471. const frame = app.captureCharFrame()
  472. expect(frame).toContain("Commands")
  473. expect(frame).toMatch(/^ {2}Commands/m)
  474. expect(frame).toContain("Search")
  475. expect(frame).toContain("Session")
  476. expect(frame).toContain("Agent")
  477. expect(frame).toContain("Prompt")
  478. expect(frame).toContain("Open editor")
  479. expect(frame).toContain("/editor")
  480. expect(frame).toContain("Show status")
  481. expect(frame).toContain("Compact session")
  482. expect(frame).toContain("/compact")
  483. expect(frame).toContain("Skills")
  484. expect(frame).toContain("/skills")
  485. expect(frame.match(/\bAgent\b/g)?.length).toBe(1)
  486. expect(frame).not.toContain("┌")
  487. expect(frame).not.toContain("┃")
  488. expect(frame).not.toContain("/internal")
  489. expect(frame).not.toContain("Choose model for future turns")
  490. expect(frame).not.toContain("Cycle reasoning effort for future turns")
  491. expect(frame).not.toContain("Review code")
  492. expect(frame).not.toContain("Commands 8")
  493. await app.mockInput.typeText("agent")
  494. await app.renderOnce()
  495. expect(app.captureCharFrame()).toContain("Switch agent")
  496. app.mockInput.pressKey("u", { ctrl: true })
  497. await app.mockInput.typeText("review")
  498. await app.renderOnce()
  499. expect(app.captureCharFrame()).toContain("No results found")
  500. app.mockInput.pressKey("u", { ctrl: true })
  501. await app.mockInput.typeText("deploy")
  502. await app.renderOnce()
  503. expect(app.captureCharFrame()).toContain("No results found")
  504. app.mockInput.pressKey("u", { ctrl: true })
  505. await app.mockInput.typeText("status")
  506. await app.renderOnce()
  507. expect(app.captureCharFrame()).toContain("Show status")
  508. app.mockInput.pressEnter()
  509. expect(status).toBe(1)
  510. } finally {
  511. app.renderer.destroy()
  512. }
  513. })
  514. test("direct settings panel changes Mini transcript preferences", async () => {
  515. const [settings, setSettings] = createSignal<MiniSettings>({
  516. thinking: "hide",
  517. shell_output: "hide",
  518. turn_summary: "show",
  519. footer: "show",
  520. splash: "show",
  521. mono: false,
  522. })
  523. const app = await testRender(
  524. () => (
  525. <box width={100} height={RUN_COMMAND_PANEL_ROWS}>
  526. <RunSettingsBody
  527. theme={() => RUN_THEME_FALLBACK.footer}
  528. settings={settings}
  529. onClose={() => {}}
  530. onChange={(change) => {
  531. setSettings((current) => ({ ...current, [change.key]: change.value }))
  532. }}
  533. mono
  534. />
  535. </box>
  536. ),
  537. { width: 100, height: RUN_COMMAND_PANEL_ROWS },
  538. )
  539. try {
  540. await app.renderOnce()
  541. const frame = app.captureCharFrame()
  542. expect(frame).toContain("Settings")
  543. expect(frame).toMatch(/^ Settings/m)
  544. expect(frame).toContain("Thinking")
  545. expect(frame).toContain("Shell")
  546. expect(frame).toContain("Turn summary")
  547. expect(frame).toContain("Footer details")
  548. expect(frame).toContain("Splash")
  549. expect(frame).toContain("Monochrome UI")
  550. expect(frame).toContain("left/right change")
  551. expect(frame).not.toMatch(/[^\x00-\x7F]/)
  552. app.mockInput.pressKey("ARROW_RIGHT")
  553. await app.renderOnce()
  554. expect(settings()).toEqual({
  555. thinking: "show",
  556. shell_output: "hide",
  557. turn_summary: "show",
  558. footer: "show",
  559. splash: "show",
  560. mono: false,
  561. })
  562. app.mockInput.pressKey("ARROW_DOWN")
  563. app.mockInput.pressKey("ARROW_DOWN")
  564. app.mockInput.pressKey("ARROW_RIGHT")
  565. await app.renderOnce()
  566. expect(settings()).toEqual({
  567. thinking: "show",
  568. shell_output: "hide",
  569. turn_summary: "hide",
  570. footer: "show",
  571. splash: "show",
  572. mono: false,
  573. })
  574. app.mockInput.pressKey("ARROW_DOWN")
  575. app.mockInput.pressKey("ARROW_DOWN")
  576. app.mockInput.pressKey("ARROW_RIGHT")
  577. await app.renderOnce()
  578. expect(settings().splash).toBe("hide")
  579. app.mockInput.pressKey("ARROW_DOWN")
  580. app.mockInput.pressKey("ARROW_RIGHT")
  581. await app.renderOnce()
  582. expect(settings().mono).toBe(true)
  583. } finally {
  584. app.renderer.destroy()
  585. }
  586. })
  587. test("direct skill panel renders searchable skill list", async () => {
  588. const [commands] = createSignal<RunCommand[] | undefined>([
  589. command({ name: "review", description: "Review code" }),
  590. command({ name: "internal", description: "Skill command", source: "skill" }),
  591. command({ name: "formatter", description: "Apply formatter fixes", source: "skill" }),
  592. ])
  593. const selected: string[] = []
  594. const app = await testRender(
  595. () => (
  596. <box width={100} height={RUN_COMMAND_PANEL_ROWS}>
  597. <RunSkillSelectBody
  598. theme={() => RUN_THEME_FALLBACK.footer}
  599. commands={commands}
  600. onClose={() => {}}
  601. onSelect={(name) => {
  602. selected.push(name)
  603. }}
  604. />
  605. </box>
  606. ),
  607. {
  608. width: 100,
  609. height: RUN_COMMAND_PANEL_ROWS,
  610. },
  611. )
  612. try {
  613. await app.renderOnce()
  614. const frame = app.captureCharFrame()
  615. expect(frame).toContain("Skills")
  616. expect(frame).toContain("Search")
  617. expect(frame).toContain("internal")
  618. expect(frame).not.toContain("/internal")
  619. expect(frame).toContain("formatter")
  620. expect(frame).toContain("Apply formatter fixes")
  621. expect(frame).not.toContain("review")
  622. await app.mockInput.typeText("format")
  623. await app.renderOnce()
  624. expect(app.captureCharFrame()).not.toContain("internal")
  625. app.mockInput.pressEnter()
  626. expect(selected).toEqual(["formatter"])
  627. } finally {
  628. app.renderer.destroy()
  629. }
  630. })
  631. test("direct skill panel truncates long descriptions from the end", async () => {
  632. const [commands] = createSignal<RunCommand[] | undefined>([
  633. command({
  634. name: "terminal-control",
  635. description:
  636. "Control and test terminal applications, REPLs, interactive CLIs, shell processes, OpenTUI applications, or other terminal-backed workflows.",
  637. source: "skill",
  638. }),
  639. ])
  640. const app = await testRender(
  641. () => (
  642. <box width={100} height={RUN_COMMAND_PANEL_ROWS}>
  643. <RunSkillSelectBody
  644. theme={() => RUN_THEME_FALLBACK.footer}
  645. commands={commands}
  646. onClose={() => {}}
  647. onSelect={() => {}}
  648. />
  649. </box>
  650. ),
  651. {
  652. width: 100,
  653. height: RUN_COMMAND_PANEL_ROWS,
  654. },
  655. )
  656. try {
  657. await app.renderOnce()
  658. const frame = app.captureCharFrame()
  659. expect(frame).toContain("terminal-control")
  660. expect(frame).toContain("Control and test terminal applications")
  661. expect(frame).not.toMatch(/application(?:…|\.\.\.)ocess/)
  662. } finally {
  663. app.renderer.destroy()
  664. }
  665. })
  666. test("direct command panel shows subagent entry when available", async () => {
  667. const [commands] = createSignal<RunCommand[] | undefined>([])
  668. const [subagents] = createSignal([subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })])
  669. const [variants] = createSignal<string[]>([])
  670. const app = await testRender(
  671. () => (
  672. <box width={100} height={RUN_COMMAND_PANEL_ROWS}>
  673. <RunCommandMenuBody
  674. theme={() => RUN_THEME_FALLBACK.footer}
  675. commands={commands}
  676. subagents={subagents}
  677. queued={() => []}
  678. variants={variants}
  679. variantCycle="ctrl+t"
  680. onClose={() => {}}
  681. onAgent={() => {}}
  682. onModel={() => {}}
  683. onEditor={() => {}}
  684. onSkill={() => {}}
  685. onSubagent={() => {}}
  686. onQueued={() => {}}
  687. onVariant={() => {}}
  688. onVariantCycle={() => {}}
  689. onStatus={() => {}}
  690. onSettings={() => {}}
  691. onCommand={() => {}}
  692. onNew={() => {}}
  693. onExit={() => {}}
  694. />
  695. </box>
  696. ),
  697. {
  698. width: 100,
  699. height: RUN_COMMAND_PANEL_ROWS,
  700. },
  701. )
  702. try {
  703. await app.renderOnce()
  704. const frame = app.captureCharFrame()
  705. expect(frame).toContain("View subagents")
  706. expect(frame).toContain("1 active")
  707. } finally {
  708. app.renderer.destroy()
  709. }
  710. })
  711. test("direct command panel keeps completed subagents available", async () => {
  712. const [commands] = createSignal<RunCommand[] | undefined>([])
  713. const [subagents] = createSignal([
  714. subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow", status: "completed" }),
  715. ])
  716. const [variants] = createSignal<string[]>([])
  717. const app = await testRender(
  718. () => (
  719. <box width={100} height={RUN_COMMAND_PANEL_ROWS}>
  720. <RunCommandMenuBody
  721. theme={() => RUN_THEME_FALLBACK.footer}
  722. commands={commands}
  723. subagents={subagents}
  724. queued={() => []}
  725. variants={variants}
  726. variantCycle="ctrl+t"
  727. onClose={() => {}}
  728. onAgent={() => {}}
  729. onModel={() => {}}
  730. onEditor={() => {}}
  731. onSkill={() => {}}
  732. onSubagent={() => {}}
  733. onQueued={() => {}}
  734. onVariant={() => {}}
  735. onVariantCycle={() => {}}
  736. onStatus={() => {}}
  737. onSettings={() => {}}
  738. onCommand={() => {}}
  739. onNew={() => {}}
  740. onExit={() => {}}
  741. />
  742. </box>
  743. ),
  744. {
  745. width: 100,
  746. height: RUN_COMMAND_PANEL_ROWS,
  747. },
  748. )
  749. try {
  750. await app.renderOnce()
  751. const frame = app.captureCharFrame()
  752. expect(frame).toContain("View subagents")
  753. expect(frame).toContain("1 recent")
  754. } finally {
  755. app.renderer.destroy()
  756. }
  757. })
  758. test("direct subagent panel toggles between active and inactive subagents", async () => {
  759. const [tabs] = createSignal([
  760. subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" }),
  761. subagent({ sessionID: "s-2", label: "General", description: "Write migration plan", status: "completed" }),
  762. ])
  763. const [current] = createSignal<string | undefined>("s-1")
  764. let rows = 0
  765. const app = await testRender(
  766. () => (
  767. <box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
  768. <RunSubagentSelectBody
  769. theme={() => RUN_THEME_FALLBACK.footer}
  770. tabs={tabs}
  771. current={current}
  772. onClose={() => {}}
  773. onSelect={() => {}}
  774. onRows={(value) => {
  775. rows = value
  776. }}
  777. />
  778. </box>
  779. ),
  780. {
  781. width: 100,
  782. height: RUN_SUBAGENT_PANEL_ROWS,
  783. },
  784. )
  785. try {
  786. await app.renderOnce()
  787. const frame = app.captureCharFrame()
  788. const list = panelMenu(app.renderer.root)
  789. expect(frame).toContain("Select subagent")
  790. expect(frame).toContain("Inspect auth flow")
  791. expect(frame).not.toContain("Write migration plan")
  792. expect(frame).not.toContain("done")
  793. expect(frame).toContain("tab show inactive")
  794. expect(frame).not.toContain("┌")
  795. expect(frame).not.toContain("┃")
  796. expectPaletteList(list, 0)
  797. expect(rows).toBe(7)
  798. app.mockInput.pressKey("TAB")
  799. await app.renderOnce()
  800. const inactive = app.captureCharFrame()
  801. expect(inactive).not.toContain("Inspect auth flow")
  802. expect(inactive).toContain("Write migration plan")
  803. expect(inactive).toContain("done")
  804. expect(inactive).toContain("tab show active")
  805. } finally {
  806. app.renderer.destroy()
  807. }
  808. })
  809. test("direct subagent panel closes when moving up from the first item", async () => {
  810. const [tabs] = createSignal([
  811. subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" }),
  812. subagent({ sessionID: "s-2", label: "General", description: "Write migration plan" }),
  813. ])
  814. const [current] = createSignal<string | undefined>()
  815. let closed = 0
  816. const app = await testRender(
  817. () => (
  818. <box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
  819. <RunSubagentSelectBody
  820. theme={() => RUN_THEME_FALLBACK.footer}
  821. tabs={tabs}
  822. current={current}
  823. onClose={() => closed++}
  824. onSelect={() => {}}
  825. />
  826. </box>
  827. ),
  828. { width: 100, height: RUN_SUBAGENT_PANEL_ROWS },
  829. )
  830. try {
  831. await app.renderOnce()
  832. app.mockInput.pressKey("ARROW_DOWN")
  833. app.mockInput.pressKey("ARROW_UP")
  834. expect(closed).toBe(0)
  835. app.mockInput.pressKey("ARROW_UP")
  836. expect(closed).toBe(1)
  837. } finally {
  838. app.renderer.destroy()
  839. }
  840. })
  841. test("direct queued panel steers and deletes selected prompts", async () => {
  842. const [prompts] = createSignal([
  843. {
  844. messageID: "m-1",
  845. prompt: { text: "fix the auth test", parts: [] },
  846. delivery: "queue" as const,
  847. },
  848. ])
  849. const steered: string[] = []
  850. const deleted: string[] = []
  851. const app = await testRender(
  852. () => (
  853. <Keymap.Provider config={tuiConfig}>
  854. <box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
  855. <RunQueuedPromptSelectBody
  856. theme={() => RUN_THEME_FALLBACK.footer}
  857. prompts={prompts}
  858. onClose={() => {}}
  859. onSteer={(prompt) => steered.push(prompt.messageID)}
  860. onDelete={(prompt) => deleted.push(prompt.messageID)}
  861. />
  862. </box>
  863. </Keymap.Provider>
  864. ),
  865. { width: 100, height: RUN_SUBAGENT_PANEL_ROWS },
  866. )
  867. try {
  868. await app.renderOnce()
  869. const frame = app.captureCharFrame()
  870. const list = panelMenu(app.renderer.root)
  871. expect(frame).toContain("Queued prompts")
  872. expect(frame).toContain("fix the auth test")
  873. expect(frame).toContain("queued")
  874. expect(frame).toContain("enter steer · ctrl+d delete")
  875. expect(frame).not.toContain("┌")
  876. expect(frame).not.toContain("┃")
  877. expectPaletteList(list, 0)
  878. app.mockInput.pressEnter()
  879. app.mockInput.pressKey("d", { ctrl: true })
  880. expect(steered).toEqual(["m-1"])
  881. expect(deleted).toEqual(["m-1"])
  882. } finally {
  883. app.renderer.destroy()
  884. }
  885. })
  886. test("direct footer steers the oldest queued prompt from an empty composer", async () => {
  887. const steered: string[] = []
  888. const app = await renderFooter({
  889. queuedPrompts: [
  890. { messageID: "m-1", prompt: { text: "first", parts: [] }, delivery: "queue" },
  891. { messageID: "m-2", prompt: { text: "second", parts: [] }, delivery: "queue" },
  892. ],
  893. onQueuedPromptAction: async (action, inboxID) => {
  894. if (action === "steer") steered.push(inboxID)
  895. },
  896. })
  897. try {
  898. await app.renderOnce()
  899. app.mockInput.pressEnter({ meta: true })
  900. await Bun.sleep(0)
  901. expect(steered).toEqual([])
  902. app.mockInput.pressEnter()
  903. await Bun.sleep(0)
  904. expect(steered).toEqual(["m-1"])
  905. } finally {
  906. app.cleanup()
  907. }
  908. })
  909. test("direct footer does not steer queued work on a double submit", async () => {
  910. const submitted: RunPrompt[] = []
  911. const steered: string[] = []
  912. const app = await renderFooter({
  913. queuedPrompts: [{ messageID: "m-1", prompt: { text: "queued", parts: [] }, delivery: "queue" }],
  914. onSubmit: async (prompt) => {
  915. submitted.push(prompt)
  916. await Bun.sleep(10)
  917. return true
  918. },
  919. onQueuedPromptAction: async (action, inboxID) => {
  920. if (action === "steer") steered.push(inboxID)
  921. },
  922. })
  923. try {
  924. await app.renderOnce()
  925. await app.mockInput.typeText("send once")
  926. app.mockInput.pressEnter()
  927. app.mockInput.pressEnter()
  928. await Bun.sleep(20)
  929. expect(submitted).toHaveLength(1)
  930. expect(steered).toEqual([])
  931. } finally {
  932. app.cleanup()
  933. }
  934. })
  935. test("direct footer rejects local commands submitted with the queue shortcut", async () => {
  936. const submitted: RunPrompt[] = []
  937. const statuses: string[] = []
  938. const app = await renderFooter({
  939. onSubmit: (prompt) => {
  940. submitted.push(prompt)
  941. return true
  942. },
  943. onStatus: (status) => statuses.push(status),
  944. })
  945. try {
  946. await app.renderOnce()
  947. await app.mockInput.typeText("/settings ")
  948. app.mockInput.pressEnter({ meta: true })
  949. await Bun.sleep(0)
  950. expect(submitted).toEqual([])
  951. expect(statuses).toContain("this prompt cannot be queued")
  952. } finally {
  953. app.cleanup()
  954. }
  955. })
  956. // OpenTUI currently crashes Bun in the full `test/cli/run` directory run here.
  957. // Re-enable after the upstream OpenTUI fix lands in this repo.
  958. test.skip("direct footer recreates the frame across command panel transitions", async () => {
  959. const app = await renderFooter()
  960. try {
  961. await app.renderOnce()
  962. for (let index = 0; index < 3; index++) {
  963. const composerFrame = footerComposerFrame(app.renderer.root)
  964. app.mockInput.pressKey("p", { ctrl: true })
  965. await app.renderOnce()
  966. expect(app.captureCharFrame()).toContain("Commands")
  967. expect(footerComposerFrame(app.renderer.root)).not.toBe(composerFrame)
  968. app.mockInput.pressKey("c", { ctrl: true })
  969. await app.renderOnce()
  970. expect(app.captureCharFrame()).not.toContain("Commands")
  971. expect(app.captureCharFrame()).not.toContain("┃")
  972. expect(app.captureCharFrame()).not.toContain("█")
  973. }
  974. } finally {
  975. app.cleanup()
  976. }
  977. })
  978. test.skip("direct footer dispatches leader variant binding only when leader is registered", async () => {
  979. const calls: string[] = []
  980. const app = await renderFooter({
  981. tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "ctrl+x", "variant.cycle": "<leader>t" } }),
  982. onCycle: () => calls.push("cycle"),
  983. })
  984. try {
  985. await app.renderOnce()
  986. app.mockInput.pressKey("t")
  987. expect(calls).toEqual([])
  988. app.mockInput.pressKey("x", { ctrl: true })
  989. app.mockInput.pressKey("t")
  990. expect(calls).toEqual(["cycle"])
  991. } finally {
  992. app.cleanup()
  993. }
  994. })
  995. test("direct footer keeps leader variant binding inactive when leader is disabled", async () => {
  996. const calls: string[] = []
  997. const app = await renderFooter({
  998. tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "none", "variant.cycle": "<leader>t" } }),
  999. onCycle: () => calls.push("cycle"),
  1000. })
  1001. try {
  1002. await app.renderOnce()
  1003. app.mockInput.pressKey("t")
  1004. app.mockInput.pressKey("x", { ctrl: true })
  1005. app.mockInput.pressKey("t")
  1006. expect(calls).toEqual([])
  1007. } finally {
  1008. app.cleanup()
  1009. }
  1010. })
  1011. test("direct footer submits slash autocomplete selections without dispatching shell completions", async () => {
  1012. const submits: RunPrompt[] = []
  1013. const app = await renderFooter({
  1014. commands: [command({ name: "review", description: "Review code" })],
  1015. onSubmit(prompt) {
  1016. submits.push(prompt)
  1017. return true
  1018. },
  1019. })
  1020. try {
  1021. await app.renderOnce()
  1022. "/rev".split("").forEach((key) => app.mockInput.pressKey(key))
  1023. await app.renderOnce()
  1024. app.mockInput.pressEnter()
  1025. await app.renderOnce()
  1026. "/rev".split("").forEach((key) => app.mockInput.pressKey(key))
  1027. await app.renderOnce()
  1028. app.mockInput.pressKey("TAB")
  1029. await app.renderOnce()
  1030. "/re branch".split("").forEach((key) => app.mockInput.pressKey(key))
  1031. Array.from({ length: 7 }).forEach(() => app.mockInput.pressKey("ARROW_LEFT"))
  1032. app.mockInput.pressKey("v")
  1033. await app.renderOnce()
  1034. app.mockInput.pressEnter()
  1035. await app.renderOnce()
  1036. "/nx".split("").forEach((key) => app.mockInput.pressKey(key))
  1037. app.mockInput.pressKey("ARROW_LEFT")
  1038. app.mockInput.pressKey("e")
  1039. await app.renderOnce()
  1040. app.mockInput.pressEnter()
  1041. await app.renderOnce()
  1042. "/n scratch".split("").forEach((key) => app.mockInput.pressKey(key))
  1043. Array.from({ length: 8 }).forEach(() => app.mockInput.pressKey("ARROW_LEFT"))
  1044. app.mockInput.pressKey("e")
  1045. await app.renderOnce()
  1046. app.mockInput.pressEnter()
  1047. await app.renderOnce()
  1048. app.mockInput.pressKey("!")
  1049. "/settings".split("").forEach((key) => app.mockInput.pressKey(key))
  1050. await app.renderOnce()
  1051. app.mockInput.pressEnter()
  1052. await app.renderOnce()
  1053. expect(submits).toEqual([
  1054. { text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" },
  1055. { text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" },
  1056. { text: "/review branch", parts: [], command: { name: "review", arguments: "branch" }, delivery: "steer" },
  1057. { text: "/new ", parts: [], delivery: "steer" },
  1058. { text: "/new ", parts: [], delivery: "steer" },
  1059. ])
  1060. expect(app.renderer.currentFocusedEditor?.plainText).toBe("/settings ")
  1061. } finally {
  1062. app.cleanup()
  1063. }
  1064. })
  1065. test("direct footer slash autocomplete keeps a real skills command", async () => {
  1066. const submits: RunPrompt[] = []
  1067. const app = await renderFooter({
  1068. commands: [
  1069. command({ name: "skills", description: "Run the real skills command" }),
  1070. command({ name: "formatter", description: "Apply formatter fixes", source: "skill" }),
  1071. ],
  1072. onSubmit(prompt) {
  1073. submits.push(prompt)
  1074. return true
  1075. },
  1076. })
  1077. try {
  1078. await app.renderOnce()
  1079. "/skills".split("").forEach((key) => app.mockInput.pressKey(key))
  1080. await app.renderOnce()
  1081. app.mockInput.pressEnter()
  1082. await app.renderOnce()
  1083. expect(submits).toEqual([
  1084. { text: "/skills ", parts: [], command: { name: "skills", arguments: "" }, delivery: "steer" },
  1085. ])
  1086. expect(app.captureCharFrame()).not.toContain("Apply formatter fixes")
  1087. } finally {
  1088. app.cleanup()
  1089. }
  1090. })
  1091. test("direct footer closes settings with ctrl-c instead of arming exit", async () => {
  1092. const app = await renderFooter({ height: 20 })
  1093. try {
  1094. await app.renderOnce()
  1095. "/settings".split("").forEach((key) => app.mockInput.pressKey(key))
  1096. await app.renderOnce()
  1097. app.mockInput.pressEnter()
  1098. await app.renderOnce()
  1099. expect(app.captureCharFrame()).toContain("Shell")
  1100. app.mockInput.pressKey("c", { ctrl: true })
  1101. await app.renderOnce()
  1102. expect(app.captureCharFrame()).not.toContain("Shell")
  1103. expect(app.renderer.currentFocusedEditor?.plainText).toBe("")
  1104. } finally {
  1105. app.cleanup()
  1106. }
  1107. })
  1108. test("selectedCommand validates the bound command and refreshes its arguments", () => {
  1109. expect(selectedCommand("/opencode-ts", { name: "opencode-ts", arguments: "", source: "skill" })).toEqual({
  1110. name: "opencode-ts",
  1111. arguments: "",
  1112. source: "skill",
  1113. })
  1114. expect(selectedCommand("/deploy prod", { name: "deploy", arguments: "" })).toEqual({
  1115. name: "deploy",
  1116. arguments: "prod",
  1117. })
  1118. expect(selectedCommand("/other", { name: "deploy", arguments: "" })).toBeUndefined()
  1119. })
  1120. test("direct footer tags skill slash submissions with their catalog source", async () => {
  1121. const submits: RunPrompt[] = []
  1122. const app = await renderFooter({
  1123. commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })],
  1124. onSubmit(prompt) {
  1125. submits.push(prompt)
  1126. return true
  1127. },
  1128. })
  1129. try {
  1130. await app.renderOnce()
  1131. "/formatter src".split("").forEach((key) => app.mockInput.pressKey(key))
  1132. await app.renderOnce()
  1133. app.mockInput.pressEnter()
  1134. await app.renderOnce()
  1135. expect(submits).toEqual([
  1136. {
  1137. text: "/formatter src",
  1138. parts: [],
  1139. command: { name: "formatter", arguments: "src", source: "skill" },
  1140. delivery: "steer",
  1141. },
  1142. ])
  1143. } finally {
  1144. app.cleanup()
  1145. }
  1146. })
  1147. test("direct footer submits a selected leading skill as a prompt attachment", async () => {
  1148. const submits: RunPrompt[] = []
  1149. const app = await renderFooter({
  1150. commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })],
  1151. onSubmit(prompt) {
  1152. submits.push(prompt)
  1153. return true
  1154. },
  1155. })
  1156. try {
  1157. await app.renderOnce()
  1158. "/forma".split("").forEach((key) => app.mockInput.pressKey(key))
  1159. await app.renderOnce()
  1160. app.mockInput.pressEnter()
  1161. await app.renderOnce()
  1162. "src".split("").forEach((key) => app.mockInput.pressKey(key))
  1163. app.mockInput.pressEnter()
  1164. await app.renderOnce()
  1165. expect(submits).toEqual([
  1166. {
  1167. text: "/formatter src",
  1168. parts: [
  1169. {
  1170. type: "skill",
  1171. id: "formatter",
  1172. source: { start: 0, end: 10, value: "/formatter" },
  1173. },
  1174. ],
  1175. delivery: "steer",
  1176. },
  1177. ])
  1178. } finally {
  1179. app.cleanup()
  1180. }
  1181. })
  1182. test("direct footer preserves a selected skill after wide text", async () => {
  1183. const submits: RunPrompt[] = []
  1184. const app = await renderFooter({
  1185. commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })],
  1186. onSubmit(prompt) {
  1187. submits.push(prompt)
  1188. return true
  1189. },
  1190. })
  1191. try {
  1192. await app.renderOnce()
  1193. "中 /forma".split("").forEach((key) => app.mockInput.pressKey(key))
  1194. await app.renderOnce()
  1195. app.mockInput.pressEnter()
  1196. await app.renderOnce()
  1197. app.mockInput.pressEnter()
  1198. await app.renderOnce()
  1199. expect(submits[0]?.parts).toEqual([
  1200. {
  1201. type: "skill",
  1202. id: "formatter",
  1203. source: { start: 3, end: 13, value: "/formatter" },
  1204. },
  1205. ])
  1206. } finally {
  1207. app.cleanup()
  1208. }
  1209. })
  1210. // OpenTUI currently segfaults Bun while tearing down this composer-to-skill-panel transition.
  1211. // Re-enable after the upstream renderer teardown fix lands.
  1212. test.skip("direct footer skill picker inserts an editable bound skill command", async () => {
  1213. const submits: RunPrompt[] = []
  1214. const app = await renderFooter({
  1215. commands: [command({ name: "new", description: "Skill named new", source: "skill" })],
  1216. onSubmit(prompt) {
  1217. submits.push(prompt)
  1218. return true
  1219. },
  1220. })
  1221. try {
  1222. await app.renderOnce()
  1223. "/skills".split("").forEach((key) => app.mockInput.pressKey(key))
  1224. await app.renderOnce()
  1225. app.mockInput.pressEnter()
  1226. await app.renderOnce()
  1227. expect(app.captureCharFrame()).toContain("Skill named new")
  1228. app.mockInput.pressEnter()
  1229. await app.renderOnce()
  1230. expect(submits).toEqual([])
  1231. expect(app.captureCharFrame()).toContain("/new")
  1232. "task".split("").forEach((key) => app.mockInput.pressKey(key))
  1233. await app.renderOnce()
  1234. app.mockInput.pressEnter()
  1235. await app.renderOnce()
  1236. expect(submits).toEqual([
  1237. { text: "/new task", parts: [], command: { name: "new", arguments: "task", source: "skill" } },
  1238. ])
  1239. } finally {
  1240. app.cleanup()
  1241. }
  1242. })
  1243. // OpenTUI currently segfaults Bun while tearing down this skill-panel close transition.
  1244. // Re-enable after the upstream renderer teardown fix lands.
  1245. test.skip("direct footer clears the synthetic skills draft when the panel closes", async () => {
  1246. const submits: RunPrompt[] = []
  1247. const app = await renderFooter({
  1248. commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })],
  1249. onSubmit(prompt) {
  1250. submits.push(prompt)
  1251. return true
  1252. },
  1253. })
  1254. try {
  1255. await app.renderOnce()
  1256. "/skills".split("").forEach((key) => app.mockInput.pressKey(key))
  1257. await app.renderOnce()
  1258. app.mockInput.pressEnter()
  1259. await app.renderOnce()
  1260. expect(app.captureCharFrame()).toContain("Apply formatter fixes")
  1261. app.mockInput.pressKey("c", { ctrl: true })
  1262. await app.renderOnce()
  1263. app.mockInput.pressEnter()
  1264. await app.renderOnce()
  1265. expect(submits).toEqual([])
  1266. expect(app.captureCharFrame()).not.toContain("/skills")
  1267. } finally {
  1268. app.cleanup()
  1269. }
  1270. })
  1271. test("direct footer shows authoritative queued work while running", async () => {
  1272. const [state] = createSignal<FooterState>({
  1273. phase: "running",
  1274. status: "",
  1275. notice: "",
  1276. model: "gpt-5",
  1277. usage: "",
  1278. first: false,
  1279. interrupt: 0,
  1280. exit: 0,
  1281. })
  1282. const [view] = createSignal<FooterView>({ type: "prompt" })
  1283. const [subagents] = createSignal<FooterSubagentState>({
  1284. tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })],
  1285. details: {},
  1286. permissions: [],
  1287. forms: [],
  1288. })
  1289. function Harness() {
  1290. return (
  1291. <Keymap.Provider config={tuiConfig}>
  1292. <RunFooterView
  1293. directory={() => "/tmp"}
  1294. findFiles={async () => []}
  1295. agents={() => []}
  1296. references={() => []}
  1297. commands={() => []}
  1298. providers={() => undefined}
  1299. currentAgent={() => "Build"}
  1300. currentAgentID={() => "build"}
  1301. currentAgentExplicit={() => false}
  1302. currentModel={() => ({
  1303. providerID: "opencode",
  1304. modelID: "a-model-name-long-enough-to-force-responsive-truncation",
  1305. })}
  1306. variants={() => []}
  1307. currentVariant={() => undefined}
  1308. state={state}
  1309. view={view}
  1310. subagent={subagents}
  1311. queuedPrompts={() => [
  1312. {
  1313. messageID: "m-queued",
  1314. prompt: { text: "follow up", parts: [] },
  1315. delivery: "queue",
  1316. },
  1317. ]}
  1318. theme={() => RUN_THEME_FALLBACK}
  1319. miniSettings={() => ({
  1320. thinking: "hide",
  1321. shell_output: "hide",
  1322. turn_summary: "show",
  1323. footer: "show",
  1324. splash: "show",
  1325. mono: false,
  1326. })}
  1327. mono={false}
  1328. onSubmit={() => true}
  1329. onPermissionReply={() => {}}
  1330. onFormReply={() => {}}
  1331. onFormCancel={() => {}}
  1332. onCycle={() => {}}
  1333. onInterrupt={() => false}
  1334. onEditorOpen={async () => undefined}
  1335. onInputClear={() => {}}
  1336. onExit={() => {}}
  1337. onAgentSelect={() => {}}
  1338. onModelSelect={() => {}}
  1339. onVariantSelect={() => {}}
  1340. onRows={() => {}}
  1341. onLayout={() => {}}
  1342. onStatus={() => {}}
  1343. onMiniSettingChange={() => {}}
  1344. />
  1345. </Keymap.Provider>
  1346. )
  1347. }
  1348. const app = await testRender(
  1349. () => (
  1350. <box width={160} height={8}>
  1351. <Harness />
  1352. </box>
  1353. ),
  1354. {
  1355. width: 160,
  1356. height: 8,
  1357. },
  1358. )
  1359. try {
  1360. await app.renderOnce()
  1361. const frame = app.captureCharFrame()
  1362. const transparent = RGBA.fromValues(0, 0, 0, 0).toInts()
  1363. const tinted = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
  1364. const statusline = footerStatusline(app.renderer.root)
  1365. const statusItems = statusline.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable)
  1366. const main = statusItems[0]
  1367. const spinner = main.getChildren()[0]
  1368. const background = statusItems[2]
  1369. const queued = statusItems[3]
  1370. const hint = statusItems.at(-1)!
  1371. expect(spinner).toBeDefined()
  1372. expect(frame).toContain("1 queued")
  1373. expect(frame).toContain("ctrl+b background")
  1374. expect(frame).toContain("ctrl+x q 1 queued")
  1375. expect(frame).toContain("↓ subagents")
  1376. expect(frame).toContain("ctrl+p cmd")
  1377. expect(frame).toContain("subagents · ctrl+p cmd")
  1378. expect(frame).not.toContain("1 agent")
  1379. expect(statusline.backgroundColor.toInts()).toEqual(tinted)
  1380. expect(main.backgroundColor.toInts()).toEqual(transparent)
  1381. expect(background.backgroundColor.toInts()).toEqual(transparent)
  1382. expect(queued.backgroundColor.toInts()).toEqual(transparent)
  1383. expect(hint.backgroundColor.toInts()).toEqual(transparent)
  1384. } finally {
  1385. app.renderer.currentFocusedRenderable?.blur()
  1386. app.renderer.currentFocusedEditor?.blur()
  1387. app.renderer.destroy()
  1388. }
  1389. })
  1390. test("direct footer progressively adds model details after the command hint", async () => {
  1391. for (const expected of [
  1392. { width: 24, agent: false, model: false, variant: false },
  1393. { width: 32, agent: false, model: true, variant: false },
  1394. { width: 40, agent: true, model: true, variant: false },
  1395. { width: 48, agent: true, model: true, variant: true },
  1396. ]) {
  1397. const app = await renderFooter({
  1398. currentAgent: "Plan",
  1399. currentVariant: "xhigh",
  1400. state: { model: "GPT-5" },
  1401. width: expected.width,
  1402. })
  1403. try {
  1404. await app.renderOnce()
  1405. const frame = app.captureCharFrame()
  1406. expect({
  1407. width: expected.width,
  1408. command: frame.includes("ctrl+p cmd"),
  1409. agent: frame.includes("Plan"),
  1410. model: frame.includes("GPT-5"),
  1411. variant: frame.includes("xhigh"),
  1412. }).toEqual({ ...expected, command: true })
  1413. } finally {
  1414. app.cleanup()
  1415. }
  1416. }
  1417. })
  1418. test("direct footer keeps commands and active work ahead of usage under width pressure", async () => {
  1419. const app = await renderFooter({
  1420. currentAgent: "Plan",
  1421. subagents: {
  1422. tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })],
  1423. details: {},
  1424. permissions: [],
  1425. forms: [],
  1426. },
  1427. state: {
  1428. phase: "running",
  1429. model: "a-model-name-long-enough-to-force-responsive-truncation",
  1430. usage: "159.6K (16%) · $4.23",
  1431. },
  1432. width: 80,
  1433. })
  1434. try {
  1435. await app.renderOnce()
  1436. const frame = app.captureCharFrame()
  1437. expect(frame).toContain("Plan")
  1438. expect(frame).toContain("ctrl+b background")
  1439. expect(frame).toContain("↓ subagents")
  1440. expect(frame).toContain("ctrl+p cmd")
  1441. expect(frame).not.toContain("a-model-name")
  1442. expect(frame).not.toContain("159.6K")
  1443. expect(frame).not.toContain("$4.23")
  1444. } finally {
  1445. app.cleanup()
  1446. }
  1447. })
  1448. test("direct footer keeps the command hint at its minimum width", async () => {
  1449. const app = await renderFooter({ state: { phase: "running" }, width: 10 })
  1450. try {
  1451. await app.renderOnce()
  1452. expect(app.captureCharFrame()).toContain("ctrl+p cmd")
  1453. } finally {
  1454. app.cleanup()
  1455. }
  1456. })
  1457. test("direct footer keeps complete status text ahead of the spinner", async () => {
  1458. const app = await renderFooter({
  1459. tuiConfig: createTuiResolvedConfig({ keybinds: { "session.interrupt": "none" } }),
  1460. state: { phase: "running" },
  1461. width: 22,
  1462. })
  1463. try {
  1464. await app.renderOnce()
  1465. expect(app.captureCharFrame()).toContain("interrupt")
  1466. expect(boxPath(footerStatusline(app.renderer.root), "SpinnerRenderable")).toBeUndefined()
  1467. } finally {
  1468. app.cleanup()
  1469. }
  1470. })
  1471. test("direct footer always offers backgrounding for a foreground subagent", async () => {
  1472. const app = await renderFooter({
  1473. subagents: {
  1474. tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })],
  1475. details: {},
  1476. permissions: [],
  1477. forms: [],
  1478. },
  1479. width: 160,
  1480. })
  1481. try {
  1482. await app.renderOnce()
  1483. const frame = app.captureCharFrame()
  1484. expect(frame).toContain("ctrl+b background · ↓ subagents · ctrl+p cmd")
  1485. expect(frame).not.toContain("queued")
  1486. } finally {
  1487. app.cleanup()
  1488. }
  1489. })
  1490. test("direct footer hides the subagent hint when only completed subagents remain", async () => {
  1491. const app = await renderFooter({
  1492. subagents: {
  1493. tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow", status: "completed" })],
  1494. details: {},
  1495. permissions: [],
  1496. forms: [],
  1497. },
  1498. width: 160,
  1499. })
  1500. try {
  1501. await app.renderOnce()
  1502. const frame = app.captureCharFrame()
  1503. expect(frame).toContain("ctrl+p cmd")
  1504. expect(frame).not.toContain("↓ subagents")
  1505. } finally {
  1506. app.cleanup()
  1507. }
  1508. })
  1509. test("direct footer omits interrupt key hint when interrupt is unbound", async () => {
  1510. const app = await renderFooter({
  1511. tuiConfig: createTuiResolvedConfig({ keybinds: { "session.interrupt": "none", "prompt.clear": "ctrl+l" } }),
  1512. state: { phase: "running" },
  1513. mono: true,
  1514. })
  1515. try {
  1516. await app.renderOnce()
  1517. const frame = app.captureCharFrame()
  1518. const statusline = frame.split("\n").find((line) => line.includes("interrupt"))
  1519. expect(frame).toContain("interrupt")
  1520. expect(frame).not.toContain("ctrl+l")
  1521. expect(statusline).toMatch(/^\S/)
  1522. } finally {
  1523. app.cleanup()
  1524. }
  1525. })
  1526. test("direct footer shows full usage metadata when room is available", async () => {
  1527. const app = await renderFooter({
  1528. state: { usage: "159.6K (16%) · $4.23" },
  1529. })
  1530. try {
  1531. await app.renderOnce()
  1532. const frame = app.captureCharFrame()
  1533. expect(frame).toContain("159.6K (16%) · $4.23")
  1534. } finally {
  1535. app.cleanup()
  1536. }
  1537. })
  1538. test("direct footer omits usage when it would fill the statusline", async () => {
  1539. const app = await renderFooter({
  1540. state: { phase: "running", model: "GPT-5.6 SoL", usage: "8.4K (1%) · $0.01" },
  1541. currentVariant: "high",
  1542. mono: true,
  1543. width: 66,
  1544. })
  1545. try {
  1546. await app.renderOnce()
  1547. const frame = app.captureCharFrame()
  1548. expect(frame).toContain("esc interrupt")
  1549. expect(frame).toContain("GPT-5.6 SoL high")
  1550. expect(frame).toContain("ctrl+p cmd")
  1551. expect(frame).not.toContain("8.4K")
  1552. } finally {
  1553. app.cleanup()
  1554. }
  1555. })
  1556. test("direct footer hides routine activity and shows explicit notices", async () => {
  1557. let status = ""
  1558. const app = await renderFooter({
  1559. state: { usage: "159.6K (16%) · $4.23" },
  1560. currentAgent: "Plan",
  1561. miniSettings: {
  1562. thinking: "hide",
  1563. shell_output: "hide",
  1564. turn_summary: "show",
  1565. footer: "hide",
  1566. splash: "show",
  1567. mono: true,
  1568. },
  1569. mono: true,
  1570. onStatus: (value) => (status = value),
  1571. width: 160,
  1572. })
  1573. try {
  1574. await app.renderOnce()
  1575. const initial = app.captureCharFrame()
  1576. expect(initial).toContain("ctrl+p cmd")
  1577. expect(initial).not.toContain("Plan")
  1578. expect(initial).not.toContain("gpt-5")
  1579. expect(initial).not.toContain("159.6K")
  1580. app.setState((state) => ({ ...state, phase: "running", status: "assistant responding" }))
  1581. await app.renderOnce()
  1582. const changed = app.captureCharFrame()
  1583. const statusline = footerStatusline(app.renderer.root)
  1584. expect(changed).not.toContain("running")
  1585. expect(changed).not.toContain("assistant responding")
  1586. expect(changed).not.toContain("interrupt")
  1587. expect(changed).not.toContain("gpt-5")
  1588. expect(changed).not.toContain("159.6K")
  1589. expect(boxPath(statusline, "SpinnerRenderable")).toBeUndefined()
  1590. app.mockInput.pressKey("p", { ctrl: true })
  1591. await app.renderOnce()
  1592. await app.mockInput.typeText("status")
  1593. app.mockInput.pressEnter()
  1594. expect(status).toBe("running - agent Plan - gpt-5 - 159.6K (16%) - $4.23")
  1595. app.setState((state) => ({ ...state, notice: "variant high" }))
  1596. await app.renderOnce()
  1597. expect(app.captureCharFrame()).toContain("variant high")
  1598. } finally {
  1599. app.cleanup()
  1600. }
  1601. })
  1602. test("direct footer does not label normal mode as build", async () => {
  1603. const app = await renderFooter()
  1604. try {
  1605. await app.renderOnce()
  1606. const statusline = app
  1607. .captureCharFrame()
  1608. .split("\n")
  1609. .find((line) => line.includes("cmd"))
  1610. expect(statusline).toBeDefined()
  1611. expect(statusline).not.toContain("BUILD")
  1612. } finally {
  1613. app.cleanup()
  1614. }
  1615. })
  1616. test("direct permission rejection submits through keymap return binding", async () => {
  1617. let text = ""
  1618. const submits: string[] = []
  1619. function Harness() {
  1620. return (
  1621. <Keymap.Provider config={tuiConfig}>
  1622. <RejectField
  1623. theme={RUN_THEME_FALLBACK.footer}
  1624. text=""
  1625. disabled={false}
  1626. onChange={(input) => {
  1627. text = input
  1628. }}
  1629. onConfirm={() => {
  1630. submits.push(text)
  1631. }}
  1632. onCancel={() => {}}
  1633. />
  1634. </Keymap.Provider>
  1635. )
  1636. }
  1637. const app = await testRender(
  1638. () => (
  1639. <box width={100} height={18}>
  1640. <Harness />
  1641. </box>
  1642. ),
  1643. { width: 100, height: 18, kittyKeyboard: true },
  1644. )
  1645. try {
  1646. await app.renderOnce()
  1647. "retry".split("").forEach((key) => app.mockInput.pressKey(key))
  1648. await app.renderOnce()
  1649. expect(app.captureCharFrame()).toContain("retry")
  1650. app.mockInput.pressEnter()
  1651. await app.renderOnce()
  1652. expect(submits).toEqual(["retry"])
  1653. } finally {
  1654. app.renderer.currentFocusedRenderable?.blur()
  1655. app.renderer.currentFocusedEditor?.blur()
  1656. app.renderer.destroy()
  1657. }
  1658. })
  1659. test("direct model panel renders current model selector", async () => {
  1660. const [providers] = createSignal<RunProvider[] | undefined>([
  1661. provider(),
  1662. { id: "openai", name: "OpenAI", models: { "gpt-5": model({ id: "gpt-5", name: "GPT-5" }) } },
  1663. ])
  1664. const [current] = createSignal<RunInput["model"]>({ providerID: "opencode", modelID: "gpt-5" })
  1665. const app = await testRender(
  1666. () => (
  1667. <box width={100} height={RUN_COMMAND_PANEL_ROWS}>
  1668. <RunModelSelectBody
  1669. theme={() => RUN_THEME_FALLBACK.footer}
  1670. providers={providers}
  1671. current={current}
  1672. onClose={() => {}}
  1673. onSelect={() => {}}
  1674. />
  1675. </box>
  1676. ),
  1677. {
  1678. width: 100,
  1679. height: RUN_COMMAND_PANEL_ROWS,
  1680. },
  1681. )
  1682. try {
  1683. await app.renderOnce()
  1684. const frame = app.captureCharFrame()
  1685. const list = panelMenu(app.renderer.root)
  1686. expect(frame).toContain("Select model")
  1687. expect(frame).toContain("Search")
  1688. expect(frame).toContain("opencode")
  1689. expect(frame).toContain("GPT-5")
  1690. expect(frame).toContain("current")
  1691. expect(frame).toContain("GPT Free")
  1692. expect(frame).toContain("Free")
  1693. expect(frame).not.toContain("┌")
  1694. expect(frame).not.toContain("┃")
  1695. expect(frame).not.toContain("Old Model")
  1696. expectPaletteList(list, 2)
  1697. "gpt-5".split("").forEach((key) => app.mockInput.pressKey(key))
  1698. await app.renderOnce()
  1699. const search = app.captureCharFrame()
  1700. expect(search.match(/GPT-5/g)).toHaveLength(2)
  1701. expect(search).toContain("opencode")
  1702. expect(search).toContain("OpenAI")
  1703. } finally {
  1704. app.renderer.destroy()
  1705. }
  1706. })
  1707. test("direct agent panel shows eligible agents and marks the current agent", async () => {
  1708. const [agents] = createSignal<RunAgent[]>([
  1709. { id: "build", name: "Build", description: "Build software", mode: "all", hidden: false },
  1710. { id: "review", name: "Review", description: "Review changes", mode: "primary", hidden: false },
  1711. { id: "explore", name: "Explore", mode: "subagent", hidden: false },
  1712. { id: "secret", name: "Secret", mode: "all", hidden: true },
  1713. ])
  1714. const [current] = createSignal("review")
  1715. let selected: string | undefined
  1716. const app = await testRender(
  1717. () => (
  1718. <box width={100} height={RUN_COMMAND_PANEL_ROWS}>
  1719. <RunAgentSelectBody
  1720. theme={() => RUN_THEME_FALLBACK.footer}
  1721. agents={agents}
  1722. current={current}
  1723. onClose={() => {}}
  1724. onSelect={(agent) => (selected = agent)}
  1725. />
  1726. </box>
  1727. ),
  1728. {
  1729. width: 100,
  1730. height: RUN_COMMAND_PANEL_ROWS,
  1731. },
  1732. )
  1733. try {
  1734. await app.renderOnce()
  1735. const frame = app.captureCharFrame()
  1736. expect(frame).toContain("Select agent")
  1737. expect(frame).toContain("build")
  1738. expect(frame).toContain("review")
  1739. expect(frame).toContain("Review changes")
  1740. expect(frame).toContain("current")
  1741. expect(frame).not.toContain("explore")
  1742. expect(frame).not.toContain("secret")
  1743. app.mockInput.pressEnter()
  1744. expect(selected).toBe("review")
  1745. } finally {
  1746. app.renderer.destroy()
  1747. }
  1748. })
  1749. test("direct variant panel renders current variant selector", async () => {
  1750. const [variants] = createSignal(["high", "minimal"])
  1751. const [current] = createSignal<string | undefined>("high")
  1752. const app = await testRender(
  1753. () => (
  1754. <box width={100} height={RUN_COMMAND_PANEL_ROWS}>
  1755. <RunVariantSelectBody
  1756. theme={() => RUN_THEME_FALLBACK.footer}
  1757. variants={variants}
  1758. current={current}
  1759. onClose={() => {}}
  1760. onSelect={() => {}}
  1761. />
  1762. </box>
  1763. ),
  1764. {
  1765. width: 100,
  1766. height: RUN_COMMAND_PANEL_ROWS,
  1767. },
  1768. )
  1769. try {
  1770. await app.renderOnce()
  1771. const frame = app.captureCharFrame()
  1772. const list = panelMenu(app.renderer.root)
  1773. expect(frame).toContain("Select variant")
  1774. expect(frame).toContain("Default")
  1775. expect(frame).toContain("high")
  1776. expect(frame).toContain("minimal")
  1777. expect(frame).toContain("current")
  1778. expect(frame).not.toContain("┌")
  1779. expect(frame).not.toContain("┃")
  1780. expectPaletteList(list, 1)
  1781. } finally {
  1782. app.renderer.destroy()
  1783. }
  1784. })