tui.test.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894
  1. import { expect } from "bun:test"
  2. import path from "path"
  3. import { pathToFileURL } from "url"
  4. import { AppNodeBuilder } from "@kirincode-ai/core/effect/app-node-builder"
  5. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  6. import { Effect, Layer } from "effect"
  7. import { FSUtil } from "@kirincode-ai/core/fs-util"
  8. import { Global } from "@kirincode-ai/core/global"
  9. import { Config } from "@/config/config"
  10. import { ConfigPlugin } from "@/config/plugin"
  11. import { CurrentWorkingDirectory } from "@/config/tui-cwd"
  12. import { TuiConfig } from "../../src/config/tui"
  13. import { TestInstance } from "../fixture/fixture"
  14. import { testEffect } from "../lib/effect"
  15. const it = testEffect(LayerNode.compile(LayerNode.group([Config.node, FSUtil.node])))
  16. const winIt = process.platform === "win32" ? it.instance : it.instance.skip
  17. const globalConfigFiles = ["kirincode.json", "kirincode.jsonc", "tui.json", "tui.jsonc"].map((file) =>
  18. path.join(Global.Path.config, file),
  19. )
  20. const cleanState = Effect.gen(function* () {
  21. const fs = yield* FSUtil.Service
  22. delete process.env.KIRINCODE_CONFIG
  23. delete process.env.KIRINCODE_TUI_CONFIG
  24. yield* Effect.forEach(globalConfigFiles, (file) => fs.remove(file, { force: true }).pipe(Effect.ignore), {
  25. discard: true,
  26. })
  27. })
  28. const withCleanState = <A, E, R>(self: Effect.Effect<A, E, R>) =>
  29. Effect.acquireUseRelease(
  30. cleanState,
  31. () => self,
  32. () => cleanState,
  33. )
  34. const withEnv = <A, E, R>(name: string, value: string | undefined, self: Effect.Effect<A, E, R>) =>
  35. Effect.acquireUseRelease(
  36. Effect.sync(() => {
  37. const previous = process.env[name]
  38. if (value === undefined) delete process.env[name]
  39. else process.env[name] = value
  40. return previous
  41. }),
  42. () => self,
  43. (previous) =>
  44. Effect.sync(() => {
  45. if (previous === undefined) delete process.env[name]
  46. else process.env[name] = previous
  47. }),
  48. )
  49. const withPlatform = <A, E, R>(platform: typeof process.platform, self: Effect.Effect<A, E, R>) =>
  50. Effect.acquireUseRelease(
  51. Effect.sync(() => {
  52. const original = Object.getOwnPropertyDescriptor(process, "platform")
  53. Object.defineProperty(process, "platform", {
  54. ...original,
  55. value: platform,
  56. })
  57. return original
  58. }),
  59. () => self,
  60. (original) =>
  61. Effect.sync(() => {
  62. if (original) Object.defineProperty(process, "platform", original)
  63. }),
  64. )
  65. const getTuiConfig = (directory: string) =>
  66. TuiConfig.Service.use((svc) => svc.get()).pipe(
  67. Effect.provide(
  68. AppNodeBuilder.build(TuiConfig.node).pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory))),
  69. ),
  70. )
  71. const getTuiPluginOrigins = (directory: string) =>
  72. TuiConfig.Service.use((svc) => svc.pluginOrigins()).pipe(
  73. Effect.provide(
  74. AppNodeBuilder.build(TuiConfig.node).pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory))),
  75. ),
  76. )
  77. it.instance("keeps server and tui plugin merge semantics aligned", () =>
  78. withCleanState(
  79. Effect.gen(function* () {
  80. const fs = yield* FSUtil.Service
  81. const test = yield* TestInstance
  82. const local = path.join(test.directory, ".kirincode")
  83. yield* fs.makeDirectory(local, { recursive: true })
  84. yield* fs.writeJson(path.join(Global.Path.config, "kirincode.json"), {
  85. plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"],
  86. })
  87. yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), {
  88. plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"],
  89. })
  90. yield* fs.writeJson(path.join(local, "kirincode.json"), {
  91. plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"],
  92. })
  93. yield* fs.writeJson(path.join(local, "tui.json"), {
  94. plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"],
  95. })
  96. const server = yield* Config.use.get()
  97. const tui = yield* getTuiConfig(test.directory)
  98. const tuiOrigins = yield* getTuiPluginOrigins(test.directory)
  99. const serverPlugins = (server.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item))
  100. const tuiPlugins = (tui.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item))
  101. expect(serverPlugins).toEqual(tuiPlugins)
  102. expect(serverPlugins).toContain("shared-plugin@2.0.0")
  103. expect(serverPlugins).not.toContain("shared-plugin@1.0.0")
  104. const serverOrigins = server.plugin_origins ?? []
  105. expect(serverOrigins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(serverPlugins)
  106. expect(tuiOrigins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(tuiPlugins)
  107. expect(serverOrigins.map((item) => item.scope)).toEqual(tuiOrigins.map((item) => item.scope))
  108. }),
  109. ),
  110. )
  111. it.instance("loads tui config with the same precedence order as server config paths", () =>
  112. withCleanState(
  113. Effect.gen(function* () {
  114. const fs = yield* FSUtil.Service
  115. const test = yield* TestInstance
  116. yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { theme: "global" })
  117. yield* fs.writeJson(path.join(test.directory, "tui.json"), { theme: "project" })
  118. yield* fs.writeWithDirs(
  119. path.join(test.directory, ".kirincode", "tui.json"),
  120. JSON.stringify({ theme: "local", diff_style: "stacked" }, null, 2),
  121. )
  122. const config = yield* getTuiConfig(test.directory)
  123. expect(config.theme).toBe("local")
  124. expect(config.diff_style).toBe("stacked")
  125. }),
  126. ),
  127. )
  128. it.instance("resolves attention config defaults and overrides", () =>
  129. withCleanState(
  130. Effect.gen(function* () {
  131. const fs = yield* FSUtil.Service
  132. const test = yield* TestInstance
  133. expect((yield* getTuiConfig(test.directory)).attention).toEqual({
  134. enabled: false,
  135. notifications: true,
  136. sound: true,
  137. volume: 0.4,
  138. sound_pack: "opencode.default",
  139. sounds: {},
  140. })
  141. yield* fs.writeJson(path.join(test.directory, "tui.json"), {
  142. attention: {
  143. enabled: false,
  144. notifications: false,
  145. sound: false,
  146. volume: 0.7,
  147. sound_pack: "acme.soft",
  148. sounds: {
  149. default: path.join(test.directory, "default.mp3"),
  150. question: pathToFileURL(path.join(test.directory, "question.mp3")).href,
  151. error: "./error.mp3",
  152. subagent_done: "./subagent-done.mp3",
  153. },
  154. },
  155. })
  156. expect((yield* getTuiConfig(test.directory)).attention).toEqual({
  157. enabled: false,
  158. notifications: false,
  159. sound: false,
  160. volume: 0.7,
  161. sound_pack: "acme.soft",
  162. sounds: {
  163. default: path.join(test.directory, "default.mp3"),
  164. question: path.join(test.directory, "question.mp3"),
  165. error: path.join(test.directory, "error.mp3"),
  166. subagent_done: path.join(test.directory, "subagent-done.mp3"),
  167. },
  168. })
  169. }),
  170. ),
  171. )
  172. it.instance("migrates tui-specific keys from kirincode.json when tui.json does not exist", () =>
  173. withCleanState(
  174. Effect.gen(function* () {
  175. const fs = yield* FSUtil.Service
  176. const test = yield* TestInstance
  177. const source = path.join(test.directory, "kirincode.json")
  178. yield* fs.writeJson(source, {
  179. theme: "migrated-theme",
  180. tui: { scroll_speed: 5 },
  181. keybinds: { app_exit: "ctrl+q" },
  182. })
  183. const config = yield* getTuiConfig(test.directory)
  184. expect(config.theme).toBe("migrated-theme")
  185. expect(config.scroll_speed).toBe(5)
  186. expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q")
  187. expect(JSON.parse(yield* fs.readFileString(path.join(test.directory, "tui.json")))).toMatchObject({
  188. theme: "migrated-theme",
  189. scroll_speed: 5,
  190. })
  191. const server = JSON.parse(yield* fs.readFileString(source))
  192. expect(server.theme).toBeUndefined()
  193. expect(server.keybinds).toBeUndefined()
  194. expect(server.tui).toBeUndefined()
  195. expect(yield* fs.existsSafe(path.join(test.directory, "kirincode.json.tui-migration.bak"))).toBe(true)
  196. expect(yield* fs.existsSafe(path.join(test.directory, "tui.json"))).toBe(true)
  197. }),
  198. ),
  199. )
  200. it.instance("migrates project legacy tui keys even when global tui.json already exists", () =>
  201. withCleanState(
  202. Effect.gen(function* () {
  203. const fs = yield* FSUtil.Service
  204. const test = yield* TestInstance
  205. yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { theme: "global" })
  206. yield* fs.writeJson(path.join(test.directory, "kirincode.json"), {
  207. theme: "project-migrated",
  208. tui: { scroll_speed: 2 },
  209. })
  210. const config = yield* getTuiConfig(test.directory)
  211. expect(config.theme).toBe("project-migrated")
  212. expect(config.scroll_speed).toBe(2)
  213. expect(yield* fs.existsSafe(path.join(test.directory, "tui.json"))).toBe(true)
  214. const server = JSON.parse(yield* fs.readFileString(path.join(test.directory, "kirincode.json")))
  215. expect(server.theme).toBeUndefined()
  216. expect(server.tui).toBeUndefined()
  217. }),
  218. ),
  219. )
  220. it.instance("drops unknown legacy tui keys during migration", () =>
  221. withCleanState(
  222. Effect.gen(function* () {
  223. const fs = yield* FSUtil.Service
  224. const test = yield* TestInstance
  225. yield* fs.writeJson(path.join(test.directory, "kirincode.json"), {
  226. theme: "migrated-theme",
  227. tui: { scroll_speed: 2, foo: 1 },
  228. })
  229. const config = yield* getTuiConfig(test.directory)
  230. expect(config.theme).toBe("migrated-theme")
  231. expect(config.scroll_speed).toBe(2)
  232. const migrated = JSON.parse(yield* fs.readFileString(path.join(test.directory, "tui.json")))
  233. expect(migrated.scroll_speed).toBe(2)
  234. expect(migrated.foo).toBeUndefined()
  235. }),
  236. ),
  237. )
  238. it.instance("skips migration when kirincode.jsonc is syntactically invalid", () =>
  239. withCleanState(
  240. Effect.gen(function* () {
  241. const fs = yield* FSUtil.Service
  242. const test = yield* TestInstance
  243. yield* fs.writeFileString(
  244. path.join(test.directory, "kirincode.jsonc"),
  245. `{
  246. "theme": "broken-theme",
  247. "tui": { "scroll_speed": 2 }
  248. "username": "still-broken"
  249. }`,
  250. )
  251. const config = yield* getTuiConfig(test.directory)
  252. expect(config.theme).toBeUndefined()
  253. expect(config.scroll_speed).toBeUndefined()
  254. expect(yield* fs.existsSafe(path.join(test.directory, "tui.json"))).toBe(false)
  255. expect(yield* fs.existsSafe(path.join(test.directory, "kirincode.jsonc.tui-migration.bak"))).toBe(false)
  256. const source = yield* fs.readFileString(path.join(test.directory, "kirincode.jsonc"))
  257. expect(source).toContain('"theme": "broken-theme"')
  258. expect(source).toContain('"tui": { "scroll_speed": 2 }')
  259. }),
  260. ),
  261. )
  262. it.instance("skips migration when tui.json already exists", () =>
  263. withCleanState(
  264. Effect.gen(function* () {
  265. const fs = yield* FSUtil.Service
  266. const test = yield* TestInstance
  267. yield* fs.writeJson(path.join(test.directory, "kirincode.json"), { theme: "legacy" })
  268. yield* fs.writeJson(path.join(test.directory, "tui.json"), { diff_style: "stacked" })
  269. const config = yield* getTuiConfig(test.directory)
  270. expect(config.diff_style).toBe("stacked")
  271. expect(config.theme).toBeUndefined()
  272. const server = JSON.parse(yield* fs.readFileString(path.join(test.directory, "kirincode.json")))
  273. expect(server.theme).toBe("legacy")
  274. expect(yield* fs.existsSafe(path.join(test.directory, "kirincode.json.tui-migration.bak"))).toBe(false)
  275. }),
  276. ),
  277. )
  278. it.instance("continues loading tui config when legacy source cannot be stripped", () =>
  279. withCleanState(
  280. Effect.gen(function* () {
  281. const fs = yield* FSUtil.Service
  282. const test = yield* TestInstance
  283. const source = path.join(test.directory, "kirincode.json")
  284. yield* fs.writeJson(source, { theme: "readonly-theme" })
  285. yield* Effect.acquireUseRelease(
  286. fs.chmod(source, 0o444),
  287. () =>
  288. Effect.gen(function* () {
  289. const config = yield* getTuiConfig(test.directory)
  290. expect(config.theme).toBe("readonly-theme")
  291. expect(yield* fs.existsSafe(path.join(test.directory, "tui.json"))).toBe(true)
  292. const server = JSON.parse(yield* fs.readFileString(source))
  293. expect(server.theme).toBe("readonly-theme")
  294. }),
  295. () => fs.chmod(source, 0o644).pipe(Effect.ignore),
  296. )
  297. }),
  298. ),
  299. )
  300. it.instance("migration backup preserves JSONC comments", () =>
  301. withCleanState(
  302. Effect.gen(function* () {
  303. const fs = yield* FSUtil.Service
  304. const test = yield* TestInstance
  305. yield* fs.writeFileString(
  306. path.join(test.directory, "kirincode.jsonc"),
  307. `{
  308. // top-level comment
  309. "theme": "jsonc-theme",
  310. "tui": {
  311. // nested comment
  312. "scroll_speed": 1.5
  313. }
  314. }`,
  315. )
  316. yield* getTuiConfig(test.directory)
  317. const backup = yield* fs.readFileString(path.join(test.directory, "kirincode.jsonc.tui-migration.bak"))
  318. expect(backup).toContain("// top-level comment")
  319. expect(backup).toContain("// nested comment")
  320. expect(backup).toContain('"theme": "jsonc-theme"')
  321. expect(backup).toContain('"scroll_speed": 1.5')
  322. }),
  323. ),
  324. )
  325. it.instance("migrates legacy tui keys across multiple kirincode.json levels", () =>
  326. withCleanState(
  327. Effect.gen(function* () {
  328. const fs = yield* FSUtil.Service
  329. const test = yield* TestInstance
  330. const nested = path.join(test.directory, "apps", "client")
  331. yield* fs.makeDirectory(nested, { recursive: true })
  332. yield* fs.writeJson(path.join(test.directory, "kirincode.json"), { theme: "root-theme" })
  333. yield* fs.writeJson(path.join(nested, "kirincode.json"), { theme: "nested-theme" })
  334. const config = yield* getTuiConfig(nested)
  335. expect(config.theme).toBe("nested-theme")
  336. expect(yield* fs.existsSafe(path.join(test.directory, "tui.json"))).toBe(true)
  337. expect(yield* fs.existsSafe(path.join(nested, "tui.json"))).toBe(true)
  338. }),
  339. ),
  340. )
  341. it.instance("flattens nested tui key inside tui.json", () =>
  342. withCleanState(
  343. Effect.gen(function* () {
  344. const fs = yield* FSUtil.Service
  345. const test = yield* TestInstance
  346. yield* fs.writeJson(path.join(test.directory, "tui.json"), {
  347. theme: "outer",
  348. tui: { scroll_speed: 3, diff_style: "stacked" },
  349. })
  350. const config = yield* getTuiConfig(test.directory)
  351. expect(config.scroll_speed).toBe(3)
  352. expect(config.diff_style).toBe("stacked")
  353. expect(config.theme).toBe("outer")
  354. }),
  355. ),
  356. )
  357. it.instance("top-level keys in tui.json take precedence over nested tui key", () =>
  358. withCleanState(
  359. Effect.gen(function* () {
  360. const fs = yield* FSUtil.Service
  361. const test = yield* TestInstance
  362. yield* fs.writeJson(path.join(test.directory, "tui.json"), {
  363. diff_style: "auto",
  364. tui: { diff_style: "stacked", scroll_speed: 2 },
  365. })
  366. const config = yield* getTuiConfig(test.directory)
  367. expect(config.diff_style).toBe("auto")
  368. expect(config.scroll_speed).toBe(2)
  369. }),
  370. ),
  371. )
  372. it.instance("project config takes precedence over KIRINCODE_TUI_CONFIG (matches KIRINCODE_CONFIG)", () =>
  373. withCleanState(
  374. Effect.gen(function* () {
  375. const fs = yield* FSUtil.Service
  376. const test = yield* TestInstance
  377. const custom = path.join(test.directory, "custom-tui.json")
  378. yield* fs.writeJson(path.join(test.directory, "tui.json"), { theme: "project", diff_style: "auto" })
  379. yield* fs.writeJson(custom, { theme: "custom", diff_style: "stacked" })
  380. yield* withEnv(
  381. "KIRINCODE_TUI_CONFIG",
  382. custom,
  383. Effect.gen(function* () {
  384. const config = yield* getTuiConfig(test.directory)
  385. expect(config.theme).toBe("project")
  386. expect(config.diff_style).toBe("auto")
  387. }),
  388. )
  389. }),
  390. ),
  391. )
  392. it.instance("merges keybind overrides across precedence layers", () =>
  393. withCleanState(
  394. Effect.gen(function* () {
  395. const fs = yield* FSUtil.Service
  396. const test = yield* TestInstance
  397. yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { keybinds: { app_exit: "ctrl+q" } })
  398. yield* fs.writeJson(path.join(test.directory, "tui.json"), { keybinds: { theme_list: "ctrl+k" } })
  399. const config = yield* getTuiConfig(test.directory)
  400. expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q")
  401. expect(config.keybinds.get("theme.switch")?.[0]?.key).toBe("ctrl+k")
  402. }),
  403. ),
  404. )
  405. it.instance("ignores unknown keybind names without dropping valid overrides from the same file", () =>
  406. withCleanState(
  407. Effect.gen(function* () {
  408. const fs = yield* FSUtil.Service
  409. const test = yield* TestInstance
  410. yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), {
  411. keybinds: {
  412. session_delete: "ctrl+d",
  413. not_a_real_keybind: "ctrl+q",
  414. },
  415. })
  416. const config = yield* getTuiConfig(test.directory)
  417. expect(config.keybinds.get("session.delete")?.[0]?.key).toBe("ctrl+d")
  418. expect(config.keybinds.get("not_a_real_keybind")).toEqual([])
  419. }),
  420. ),
  421. )
  422. it.instance("resolves keybind lookup from canonical keybinds", () =>
  423. withCleanState(
  424. Effect.gen(function* () {
  425. const fs = yield* FSUtil.Service
  426. const test = yield* TestInstance
  427. yield* fs.writeJson(path.join(test.directory, "tui.json"), {
  428. keybinds: {
  429. leader: { key: { name: "g", ctrl: true } },
  430. command_list: "alt+p",
  431. diff_open: "ctrl+j",
  432. which_key_toggle: "alt+k",
  433. editor_open: "ctrl+e",
  434. "prompt.autocomplete.next": "ctrl+j",
  435. "dialog.prompt.submit": "ctrl+s",
  436. "dialog.mcp.toggle": "ctrl+t",
  437. model_favorite_toggle: "ctrl+f",
  438. "dialog.plugins.install": "shift+i",
  439. },
  440. leader_timeout: 1234,
  441. })
  442. const config = yield* getTuiConfig(test.directory)
  443. expect(config.keybinds.get("leader")?.[0]?.key).toEqual({ name: "g", ctrl: true })
  444. expect(config.leader_timeout).toBe(1234)
  445. expect(config.keybinds.get("command.palette.show")?.[0]?.key).toBe("alt+p")
  446. expect(config.keybinds.get("diff.open")?.[0]?.key).toBe("ctrl+j")
  447. expect(config.keybinds.get("session.new")?.[0]?.key).toBe("<leader>n")
  448. expect(config.keybinds.get("which-key.toggle")?.[0]?.key).toBe("alt+k")
  449. expect(config.keybinds.get("which-key.layout.toggle")?.[0]?.key).toBe("ctrl+alt+shift+k")
  450. expect(config.keybinds.get("which-key.pending.toggle")?.[0]?.key).toBe("ctrl+alt+shift+p")
  451. expect(config.keybinds.get("which-key.group.next")?.[0]?.key).toBe("ctrl+alt+right,ctrl+alt+]")
  452. expect((config.keybinds.get("which-key.toggle")?.[0] as { desc?: unknown } | undefined)?.desc).toBe(
  453. "Toggle which-key panel",
  454. )
  455. expect(config.keybinds.get("prompt.editor")?.[0]?.key).toBe("ctrl+e")
  456. expect(config.keybinds.get("prompt.autocomplete.next")?.[0]?.key).toBe("ctrl+j")
  457. expect(config.keybinds.get("dialog.prompt.submit")?.[0]?.key).toBe("ctrl+s")
  458. expect(config.keybinds.get("dialog.mcp.toggle")?.[0]?.key).toBe("ctrl+t")
  459. expect(config.keybinds.get("model.dialog.favorite")?.[0]?.key).toBe("ctrl+f")
  460. expect(config.keybinds.get("dialog.plugins.install")?.[0]?.key).toBe("shift+i")
  461. expect(
  462. config.keybinds.gather("plugins.dialog", ["dialog.plugins.install"]).map((binding) => binding.cmd),
  463. ).toEqual(["dialog.plugins.install"])
  464. }),
  465. ),
  466. )
  467. it.instance("keybinds accept OpenTUI binding specs", () =>
  468. withCleanState(
  469. Effect.gen(function* () {
  470. const fs = yield* FSUtil.Service
  471. const test = yield* TestInstance
  472. yield* fs.writeJson(path.join(test.directory, "tui.json"), {
  473. keybinds: {
  474. command_list: [{ key: "alt+p", preventDefault: false }],
  475. editor_open: { key: { name: "e", ctrl: true }, group: "Explicit" },
  476. "prompt.autocomplete.next": false,
  477. plugin_manager: "ctrl+shift+p",
  478. },
  479. })
  480. const config = yield* getTuiConfig(test.directory)
  481. expect(config.keybinds.get("command.palette.show")).toEqual([
  482. { key: "alt+p", cmd: "command.palette.show", preventDefault: false, desc: "List available commands" },
  483. ])
  484. expect(config.keybinds.get("prompt.editor")?.[0]).toMatchObject({
  485. key: { name: "e", ctrl: true },
  486. cmd: "prompt.editor",
  487. group: "Explicit",
  488. })
  489. expect(config.keybinds.get("prompt.autocomplete.next")).toEqual([])
  490. expect(config.keybinds.get("plugins.list")?.[0]?.key).toBe("ctrl+shift+p")
  491. }),
  492. ),
  493. )
  494. winIt("defaults Ctrl+Z to input undo on Windows", () =>
  495. withCleanState(
  496. Effect.gen(function* () {
  497. const test = yield* TestInstance
  498. const config = yield* getTuiConfig(test.directory)
  499. expect(config.keybinds.get("terminal.suspend")).toEqual([])
  500. expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z")
  501. }),
  502. ),
  503. )
  504. winIt("keeps explicit input undo overrides on Windows", () =>
  505. withCleanState(
  506. Effect.gen(function* () {
  507. const fs = yield* FSUtil.Service
  508. const test = yield* TestInstance
  509. yield* fs.writeJson(path.join(test.directory, "tui.json"), { keybinds: { input_undo: "ctrl+y" } })
  510. const config = yield* getTuiConfig(test.directory)
  511. expect(config.keybinds.get("terminal.suspend")).toEqual([])
  512. expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y")
  513. }),
  514. ),
  515. )
  516. winIt("ignores terminal suspend bindings on Windows", () =>
  517. withCleanState(
  518. Effect.gen(function* () {
  519. const fs = yield* FSUtil.Service
  520. const test = yield* TestInstance
  521. yield* fs.writeJson(path.join(test.directory, "tui.json"), { keybinds: { terminal_suspend: "alt+z" } })
  522. const config = yield* getTuiConfig(test.directory)
  523. expect(config.keybinds.get("terminal.suspend")).toEqual([])
  524. expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z")
  525. }),
  526. ),
  527. )
  528. it.instance("applies Windows keybind defaults", () =>
  529. withCleanState(
  530. withPlatform(
  531. "win32",
  532. Effect.gen(function* () {
  533. const test = yield* TestInstance
  534. const config = yield* getTuiConfig(test.directory)
  535. expect(config.keybinds.get("terminal.suspend")).toEqual([])
  536. expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z")
  537. }),
  538. ),
  539. ),
  540. )
  541. it.instance("ignores explicit keybind terminal suspend binding on Windows", () =>
  542. withCleanState(
  543. withPlatform(
  544. "win32",
  545. Effect.gen(function* () {
  546. const fs = yield* FSUtil.Service
  547. const test = yield* TestInstance
  548. yield* fs.writeJson(path.join(test.directory, "tui.json"), {
  549. keybinds: {
  550. terminal_suspend: "alt+z",
  551. },
  552. })
  553. const config = yield* getTuiConfig(test.directory)
  554. expect(config.keybinds.get("terminal.suspend")).toEqual([])
  555. }),
  556. ),
  557. ),
  558. )
  559. it.instance("keeps explicit configured keybind input undo on Windows", () =>
  560. withCleanState(
  561. withPlatform(
  562. "win32",
  563. Effect.gen(function* () {
  564. const fs = yield* FSUtil.Service
  565. const test = yield* TestInstance
  566. yield* fs.writeJson(path.join(test.directory, "tui.json"), {
  567. keybinds: {
  568. input_undo: "ctrl+y",
  569. },
  570. })
  571. const config = yield* getTuiConfig(test.directory)
  572. expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y")
  573. }),
  574. ),
  575. ),
  576. )
  577. it.instance("KIRINCODE_TUI_CONFIG provides settings when no project config exists", () =>
  578. withCleanState(
  579. Effect.gen(function* () {
  580. const fs = yield* FSUtil.Service
  581. const test = yield* TestInstance
  582. const custom = path.join(test.directory, "custom-tui.json")
  583. yield* fs.writeJson(custom, { theme: "from-env", diff_style: "stacked" })
  584. yield* withEnv(
  585. "KIRINCODE_TUI_CONFIG",
  586. custom,
  587. Effect.gen(function* () {
  588. const config = yield* getTuiConfig(test.directory)
  589. expect(config.theme).toBe("from-env")
  590. expect(config.diff_style).toBe("stacked")
  591. }),
  592. )
  593. }),
  594. ),
  595. )
  596. it.instance("does not derive tui path from KIRINCODE_CONFIG", () =>
  597. withCleanState(
  598. Effect.gen(function* () {
  599. const fs = yield* FSUtil.Service
  600. const test = yield* TestInstance
  601. const customDir = path.join(test.directory, "custom")
  602. yield* fs.makeDirectory(customDir, { recursive: true })
  603. yield* fs.writeJson(path.join(customDir, "kirincode.json"), { model: "test/model" })
  604. yield* fs.writeJson(path.join(customDir, "tui.json"), { theme: "should-not-load" })
  605. yield* withEnv(
  606. "KIRINCODE_CONFIG",
  607. path.join(customDir, "kirincode.json"),
  608. Effect.gen(function* () {
  609. const config = yield* getTuiConfig(test.directory)
  610. expect(config.theme).toBeUndefined()
  611. }),
  612. )
  613. }),
  614. ),
  615. )
  616. it.instance("applies env and file substitutions in tui.json", () =>
  617. withCleanState(
  618. withEnv(
  619. "TUI_THEME_TEST",
  620. "env-theme",
  621. Effect.gen(function* () {
  622. const fs = yield* FSUtil.Service
  623. const test = yield* TestInstance
  624. yield* fs.writeFileString(path.join(test.directory, "keybind.txt"), "ctrl+q")
  625. yield* fs.writeJson(path.join(test.directory, "tui.json"), {
  626. theme: "{env:TUI_THEME_TEST}",
  627. keybinds: { app_exit: "{file:keybind.txt}" },
  628. })
  629. const config = yield* getTuiConfig(test.directory)
  630. expect(config.theme).toBe("env-theme")
  631. expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q")
  632. }),
  633. ),
  634. ),
  635. )
  636. it.instance("applies file substitutions when first identical token is in a commented line", () =>
  637. withCleanState(
  638. Effect.gen(function* () {
  639. const fs = yield* FSUtil.Service
  640. const test = yield* TestInstance
  641. yield* fs.writeFileString(path.join(test.directory, "theme.txt"), "resolved-theme")
  642. yield* fs.writeFileString(
  643. path.join(test.directory, "tui.jsonc"),
  644. `{
  645. // "theme": "{file:theme.txt}",
  646. "theme": "{file:theme.txt}"
  647. }`,
  648. )
  649. const config = yield* getTuiConfig(test.directory)
  650. expect(config.theme).toBe("resolved-theme")
  651. }),
  652. ),
  653. )
  654. it.instance("loads .kirincode/tui.json", () =>
  655. withCleanState(
  656. Effect.gen(function* () {
  657. const fs = yield* FSUtil.Service
  658. const test = yield* TestInstance
  659. yield* fs.writeWithDirs(
  660. path.join(test.directory, ".kirincode", "tui.json"),
  661. JSON.stringify({ diff_style: "stacked" }, null, 2),
  662. )
  663. const config = yield* getTuiConfig(test.directory)
  664. expect(config.diff_style).toBe("stacked")
  665. }),
  666. ),
  667. )
  668. it.instance("supports tuple plugin specs with options in tui.json", () =>
  669. withCleanState(
  670. Effect.gen(function* () {
  671. const fs = yield* FSUtil.Service
  672. const test = yield* TestInstance
  673. yield* fs.writeJson(path.join(test.directory, "tui.json"), {
  674. plugin: [["acme-plugin@1.2.3", { enabled: true, label: "demo" }]],
  675. })
  676. const config = yield* getTuiConfig(test.directory)
  677. const origins = yield* getTuiPluginOrigins(test.directory)
  678. expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
  679. expect(origins).toEqual([
  680. {
  681. spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }],
  682. scope: "local",
  683. source: path.join(test.directory, "tui.json"),
  684. },
  685. ])
  686. }),
  687. ),
  688. )
  689. it.instance("deduplicates tuple plugin specs by name with higher precedence winning", () =>
  690. withCleanState(
  691. Effect.gen(function* () {
  692. const fs = yield* FSUtil.Service
  693. const test = yield* TestInstance
  694. yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), {
  695. plugin: [["acme-plugin@1.0.0", { source: "global" }]],
  696. })
  697. yield* fs.writeJson(path.join(test.directory, "tui.json"), {
  698. plugin: [
  699. ["acme-plugin@2.0.0", { source: "project" }],
  700. ["second-plugin@3.0.0", { source: "project" }],
  701. ],
  702. })
  703. const config = yield* getTuiConfig(test.directory)
  704. const origins = yield* getTuiPluginOrigins(test.directory)
  705. expect(config.plugin).toEqual([
  706. ["acme-plugin@2.0.0", { source: "project" }],
  707. ["second-plugin@3.0.0", { source: "project" }],
  708. ])
  709. expect(origins).toEqual([
  710. {
  711. spec: ["acme-plugin@2.0.0", { source: "project" }],
  712. scope: "local",
  713. source: path.join(test.directory, "tui.json"),
  714. },
  715. {
  716. spec: ["second-plugin@3.0.0", { source: "project" }],
  717. scope: "local",
  718. source: path.join(test.directory, "tui.json"),
  719. },
  720. ])
  721. }),
  722. ),
  723. )
  724. it.instance("tracks global and local plugin metadata in merged tui config", () =>
  725. withCleanState(
  726. Effect.gen(function* () {
  727. const fs = yield* FSUtil.Service
  728. const test = yield* TestInstance
  729. yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { plugin: ["global-plugin@1.0.0"] })
  730. yield* fs.writeJson(path.join(test.directory, "tui.json"), { plugin: ["local-plugin@2.0.0"] })
  731. const config = yield* getTuiConfig(test.directory)
  732. const origins = yield* getTuiPluginOrigins(test.directory)
  733. expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"])
  734. expect(origins).toEqual([
  735. {
  736. spec: "global-plugin@1.0.0",
  737. scope: "global",
  738. source: path.join(Global.Path.config, "tui.json"),
  739. },
  740. {
  741. spec: "local-plugin@2.0.0",
  742. scope: "local",
  743. source: path.join(test.directory, "tui.json"),
  744. },
  745. ])
  746. }),
  747. ),
  748. )
  749. it.instance("merges plugin_enabled flags across config layers", () =>
  750. withCleanState(
  751. Effect.gen(function* () {
  752. const fs = yield* FSUtil.Service
  753. const test = yield* TestInstance
  754. yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), {
  755. plugin_enabled: {
  756. "internal:sidebar-context": false,
  757. "demo.plugin": true,
  758. },
  759. })
  760. yield* fs.writeJson(path.join(test.directory, "tui.json"), {
  761. plugin_enabled: {
  762. "demo.plugin": false,
  763. "local.plugin": true,
  764. },
  765. })
  766. const config = yield* getTuiConfig(test.directory)
  767. expect(config.plugin_enabled).toEqual({
  768. "internal:sidebar-context": false,
  769. "demo.plugin": false,
  770. "local.plugin": true,
  771. })
  772. }),
  773. ),
  774. )
  775. it.instance("silently skips malformed tui.json - load failures degrade to {}", () =>
  776. withCleanState(
  777. Effect.gen(function* () {
  778. const fs = yield* FSUtil.Service
  779. const test = yield* TestInstance
  780. yield* fs.writeFileString(path.join(test.directory, "tui.json"), '{ "theme": "broken",')
  781. yield* fs.writeWithDirs(path.join(test.directory, ".kirincode", "tui.json"), JSON.stringify({ theme: "fallback" }))
  782. const config = yield* getTuiConfig(test.directory)
  783. expect(config.theme).toBe("fallback")
  784. }),
  785. ),
  786. )
  787. it.instance("silently skips non-ENOENT read failures (e.g. tui.json is a directory) - fallback layer still loads", () =>
  788. withCleanState(
  789. Effect.gen(function* () {
  790. const fs = yield* FSUtil.Service
  791. const test = yield* TestInstance
  792. yield* fs.makeDirectory(path.join(test.directory, "tui.json"), { recursive: true })
  793. yield* fs.writeWithDirs(path.join(test.directory, ".kirincode", "tui.json"), JSON.stringify({ theme: "fallback" }))
  794. const config = yield* getTuiConfig(test.directory)
  795. expect(config.theme).toBe("fallback")
  796. }),
  797. ),
  798. )
  799. it.instance("missing tui.json - silently treated as empty (ENOENT path)", () =>
  800. withCleanState(
  801. Effect.gen(function* () {
  802. const test = yield* TestInstance
  803. const config = yield* getTuiConfig(test.directory)
  804. expect(config).toBeDefined()
  805. expect(config.theme).toBeUndefined()
  806. }),
  807. ),
  808. )