@neurocode-ai/tui 1.18.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bunfig.toml +4 -0
- package/package.json +72 -0
- package/src/app.tsx +1134 -0
- package/src/attention.ts +260 -0
- package/src/audio.d.ts +9 -0
- package/src/audio.ts +53 -0
- package/src/clipboard.ts +124 -0
- package/src/component/bg-pulse-render.ts +436 -0
- package/src/component/bg-pulse.tsx +99 -0
- package/src/component/command-palette.tsx +79 -0
- package/src/component/dialog-agent.tsx +31 -0
- package/src/component/dialog-console-org.tsx +135 -0
- package/src/component/dialog-debug.tsx +90 -0
- package/src/component/dialog-mcp.tsx +85 -0
- package/src/component/dialog-model.tsx +197 -0
- package/src/component/dialog-move-session.tsx +353 -0
- package/src/component/dialog-provider.tsx +469 -0
- package/src/component/dialog-retry-action.tsx +160 -0
- package/src/component/dialog-session-delete-failed.tsx +99 -0
- package/src/component/dialog-session-list.tsx +364 -0
- package/src/component/dialog-session-rename.tsx +31 -0
- package/src/component/dialog-skill.tsx +70 -0
- package/src/component/dialog-stash.tsx +87 -0
- package/src/component/dialog-status.tsx +168 -0
- package/src/component/dialog-tag.tsx +47 -0
- package/src/component/dialog-theme-list.tsx +50 -0
- package/src/component/dialog-variant.tsx +39 -0
- package/src/component/dialog-workspace-create.tsx +308 -0
- package/src/component/dialog-workspace-file-changes.tsx +144 -0
- package/src/component/dialog-workspace-list.tsx +112 -0
- package/src/component/dialog-workspace-unavailable.tsx +69 -0
- package/src/component/error-component.tsx +240 -0
- package/src/component/logo.tsx +61 -0
- package/src/component/plugin-route-missing.tsx +14 -0
- package/src/component/prompt/autocomplete.tsx +781 -0
- package/src/component/prompt/cwd.ts +0 -0
- package/src/component/prompt/frecency.tsx +1 -0
- package/src/component/prompt/history.tsx +1 -0
- package/src/component/prompt/index.tsx +1713 -0
- package/src/component/prompt/local-attachment.ts +48 -0
- package/src/component/prompt/move.tsx +205 -0
- package/src/component/prompt/stash.tsx +1 -0
- package/src/component/prompt/workspace.tsx +137 -0
- package/src/component/register-spinner.ts +6 -0
- package/src/component/spinner.tsx +26 -0
- package/src/component/startup-loading.tsx +63 -0
- package/src/component/todo-item.tsx +32 -0
- package/src/component/use-connected.tsx +12 -0
- package/src/component/workspace-label.tsx +19 -0
- package/src/config/index.tsx +129 -0
- package/src/config/keybind.ts +471 -0
- package/src/context/args.tsx +16 -0
- package/src/context/clipboard.tsx +18 -0
- package/src/context/data.tsx +569 -0
- package/src/context/directory.ts +17 -0
- package/src/context/editor.ts +408 -0
- package/src/context/epilogue.tsx +6 -0
- package/src/context/event.ts +36 -0
- package/src/context/exit.tsx +8 -0
- package/src/context/helper.tsx +26 -0
- package/src/context/kv.tsx +66 -0
- package/src/context/local.tsx +542 -0
- package/src/context/location.tsx +14 -0
- package/src/context/path-format.tsx +24 -0
- package/src/context/permission.tsx +26 -0
- package/src/context/project.tsx +115 -0
- package/src/context/prompt.tsx +18 -0
- package/src/context/route.tsx +60 -0
- package/src/context/runtime.tsx +62 -0
- package/src/context/sdk.tsx +151 -0
- package/src/context/sync.tsx +666 -0
- package/src/context/theme.tsx +332 -0
- package/src/context/thinking.ts +67 -0
- package/src/editor-zed.ts +286 -0
- package/src/editor.ts +101 -0
- package/src/feature-plugins/builtins.ts +36 -0
- package/src/feature-plugins/home/footer.tsx +100 -0
- package/src/feature-plugins/home/tips-view.tsx +287 -0
- package/src/feature-plugins/home/tips.tsx +59 -0
- package/src/feature-plugins/sidebar/context.tsx +65 -0
- package/src/feature-plugins/sidebar/files.tsx +70 -0
- package/src/feature-plugins/sidebar/footer.tsx +98 -0
- package/src/feature-plugins/sidebar/lsp.tsx +65 -0
- package/src/feature-plugins/sidebar/mcp.tsx +97 -0
- package/src/feature-plugins/sidebar/todo.tsx +49 -0
- package/src/feature-plugins/system/diff-viewer-file-tree-utils.ts +232 -0
- package/src/feature-plugins/system/diff-viewer-file-tree.tsx +162 -0
- package/src/feature-plugins/system/diff-viewer-ui.tsx +103 -0
- package/src/feature-plugins/system/diff-viewer.tsx +1077 -0
- package/src/feature-plugins/system/notifications.ts +94 -0
- package/src/feature-plugins/system/plugins.tsx +269 -0
- package/src/feature-plugins/system/which-key.tsx +608 -0
- package/src/index.tsx +1 -0
- package/src/keymap.tsx +290 -0
- package/src/logo.ts +11 -0
- package/src/parsers-config.ts +386 -0
- package/src/plugin/adapters.tsx +355 -0
- package/src/plugin/api.ts +52 -0
- package/src/plugin/command-shim.ts +109 -0
- package/src/plugin/runtime.tsx +81 -0
- package/src/plugin/slots.tsx +65 -0
- package/src/prompt/display.ts +48 -0
- package/src/prompt/frecency.tsx +80 -0
- package/src/prompt/history.tsx +111 -0
- package/src/prompt/part.ts +29 -0
- package/src/prompt/stash.tsx +89 -0
- package/src/prompt/traits.ts +29 -0
- package/src/routes/home/session-destination.tsx +41 -0
- package/src/routes/home.tsx +95 -0
- package/src/routes/session/dialog-fork-from-timeline.tsx +76 -0
- package/src/routes/session/dialog-message.tsx +109 -0
- package/src/routes/session/dialog-subagent.tsx +26 -0
- package/src/routes/session/dialog-timeline.tsx +47 -0
- package/src/routes/session/footer.tsx +91 -0
- package/src/routes/session/index.tsx +2710 -0
- package/src/routes/session/permission.tsx +718 -0
- package/src/routes/session/question.tsx +514 -0
- package/src/routes/session/sidebar.tsx +103 -0
- package/src/routes/session/subagent-footer.tsx +132 -0
- package/src/runtime.tsx +9 -0
- package/src/terminal-win32.ts +130 -0
- package/src/theme/assets/aura.json +69 -0
- package/src/theme/assets/ayu.json +80 -0
- package/src/theme/assets/carbonfox.json +248 -0
- package/src/theme/assets/catppuccin-frappe.json +230 -0
- package/src/theme/assets/catppuccin-macchiato.json +230 -0
- package/src/theme/assets/catppuccin.json +112 -0
- package/src/theme/assets/cobalt2.json +225 -0
- package/src/theme/assets/cursor.json +249 -0
- package/src/theme/assets/dracula.json +219 -0
- package/src/theme/assets/everforest.json +241 -0
- package/src/theme/assets/flexoki.json +237 -0
- package/src/theme/assets/github.json +233 -0
- package/src/theme/assets/gruvbox.json +242 -0
- package/src/theme/assets/kanagawa.json +77 -0
- package/src/theme/assets/lucent-orng.json +234 -0
- package/src/theme/assets/material.json +235 -0
- package/src/theme/assets/matrix.json +77 -0
- package/src/theme/assets/mercury.json +252 -0
- package/src/theme/assets/monokai.json +221 -0
- package/src/theme/assets/nightowl.json +221 -0
- package/src/theme/assets/nord.json +223 -0
- package/src/theme/assets/one-dark.json +84 -0
- package/src/theme/assets/opencode.json +245 -0
- package/src/theme/assets/orng.json +249 -0
- package/src/theme/assets/osaka-jade.json +93 -0
- package/src/theme/assets/palenight.json +222 -0
- package/src/theme/assets/rosepine.json +234 -0
- package/src/theme/assets/solarized.json +223 -0
- package/src/theme/assets/synthwave84.json +226 -0
- package/src/theme/assets/tokyonight.json +243 -0
- package/src/theme/assets/vercel.json +245 -0
- package/src/theme/assets/vesper.json +218 -0
- package/src/theme/assets/zenburn.json +223 -0
- package/src/theme/index.ts +1089 -0
- package/src/ui/border.ts +21 -0
- package/src/ui/dialog-alert.tsx +66 -0
- package/src/ui/dialog-confirm.tsx +108 -0
- package/src/ui/dialog-export-options.tsx +217 -0
- package/src/ui/dialog-help.tsx +40 -0
- package/src/ui/dialog-prompt.tsx +126 -0
- package/src/ui/dialog-select.tsx +790 -0
- package/src/ui/dialog.tsx +231 -0
- package/src/ui/link.tsx +34 -0
- package/src/ui/spinner.ts +368 -0
- package/src/ui/toast.tsx +102 -0
- package/src/util/collapse-tool-output.ts +19 -0
- package/src/util/error.ts +182 -0
- package/src/util/filetype.ts +130 -0
- package/src/util/format.ts +20 -0
- package/src/util/layout.ts +25 -0
- package/src/util/locale.ts +86 -0
- package/src/util/model.ts +28 -0
- package/src/util/path.ts +12 -0
- package/src/util/persistence.ts +33 -0
- package/src/util/presentation.ts +38 -0
- package/src/util/provider-origin.ts +7 -0
- package/src/util/record.ts +3 -0
- package/src/util/renderer.ts +7 -0
- package/src/util/revert-diff.ts +18 -0
- package/src/util/scroll.ts +27 -0
- package/src/util/selection.ts +79 -0
- package/src/util/session.ts +3 -0
- package/src/util/signal.ts +51 -0
- package/src/util/system.ts +20 -0
- package/src/util/tool-display.ts +13 -0
- package/src/util/transcript.ts +112 -0
- package/sst-env.d.ts +10 -0
- package/test/app-lifecycle.test.tsx +128 -0
- package/test/cli/cmd/tui/dialog-workspace-create.test.ts +28 -0
- package/test/cli/cmd/tui/model-options.test.ts +32 -0
- package/test/cli/cmd/tui/notifications.test.ts +267 -0
- package/test/cli/cmd/tui/provider-options.test.ts +41 -0
- package/test/cli/cmd/tui/sync-fixture.tsx +70 -0
- package/test/cli/cmd/tui/sync-live-hydration.test.tsx +262 -0
- package/test/cli/cmd/tui/sync-undefined-messages.test.tsx +43 -0
- package/test/cli/cmd/tui/sync.test.tsx +65 -0
- package/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +92 -0
- package/test/cli/tui/data.test.tsx +486 -0
- package/test/cli/tui/dialog-prompt.test.tsx +147 -0
- package/test/cli/tui/diff-viewer-file-tree.test.tsx +200 -0
- package/test/cli/tui/diff-viewer.test.tsx +268 -0
- package/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +351 -0
- package/test/cli/tui/prompt-submit-race.test.ts +98 -0
- package/test/cli/tui/thinking.test.ts +36 -0
- package/test/cli/tui/use-event.test.tsx +148 -0
- package/test/clipboard.test.ts +19 -0
- package/test/component/dialog-session-list.test.ts +46 -0
- package/test/config.test.tsx +127 -0
- package/test/context/local.test.ts +22 -0
- package/test/editor.test.ts +32 -0
- package/test/feature-plugins/diff-viewer-file-tree-utils.test.ts +323 -0
- package/test/fixture/fixture.ts +13 -0
- package/test/fixture/tui-environment.tsx +32 -0
- package/test/fixture/tui-plugin.ts +36 -0
- package/test/fixture/tui-runtime.ts +12 -0
- package/test/fixture/tui-sdk.ts +109 -0
- package/test/index.test.tsx +6 -0
- package/test/keymap.test.tsx +141 -0
- package/test/plugin/runtime.test.ts +50 -0
- package/test/plugin/slots.test.tsx +38 -0
- package/test/prompt/display.test.ts +33 -0
- package/test/prompt/history.test.ts +39 -0
- package/test/prompt/jsonl.test.ts +24 -0
- package/test/prompt/local-attachment.test.ts +43 -0
- package/test/prompt/part.test.ts +53 -0
- package/test/prompt/persistence.test.ts +23 -0
- package/test/prompt/traits.test.ts +25 -0
- package/test/runtime.test.tsx +37 -0
- package/test/theme.test.ts +81 -0
- package/test/util/error.test.ts +49 -0
- package/test/util/filetype.test.ts +16 -0
- package/test/util/format.test.ts +59 -0
- package/test/util/model.test.ts +9 -0
- package/test/util/presentation.test.ts +8 -0
- package/test/util/renderer.test.ts +30 -0
- package/test/util/revert-diff.test.ts +35 -0
- package/test/util/session.test.ts +10 -0
- package/test/util/tool-display.test.ts +40 -0
- package/test/util/transcript.test.ts +421 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { GlobalEvent } from "@neurocode-ai/sdk/v2"
|
|
2
|
+
import type { EventSource } from "../../src/context/sdk"
|
|
3
|
+
|
|
4
|
+
export const worktree = "/tmp/opencode"
|
|
5
|
+
export const directory = `${worktree}/packages/tui`
|
|
6
|
+
|
|
7
|
+
export function json(data: unknown, init?: ResponseInit) {
|
|
8
|
+
return new Response(JSON.stringify(data), {
|
|
9
|
+
...init,
|
|
10
|
+
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function eventSource(): EventSource {
|
|
15
|
+
return { subscribe: async () => () => {} }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createEventSource() {
|
|
19
|
+
let fn: ((event: GlobalEvent) => void) | undefined
|
|
20
|
+
let stream: ReadableStreamDefaultController<Uint8Array> | undefined
|
|
21
|
+
const pending: Uint8Array[] = []
|
|
22
|
+
return {
|
|
23
|
+
source: {
|
|
24
|
+
subscribe: async (handler: (event: GlobalEvent) => void) => {
|
|
25
|
+
fn = handler
|
|
26
|
+
return () => {
|
|
27
|
+
if (fn === handler) fn = undefined
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
} satisfies EventSource,
|
|
31
|
+
emit(event: GlobalEvent) {
|
|
32
|
+
if (!fn) throw new Error("event source not ready")
|
|
33
|
+
fn(event)
|
|
34
|
+
if (!("properties" in event.payload)) return
|
|
35
|
+
const chunk = new TextEncoder().encode(
|
|
36
|
+
`data: ${JSON.stringify({
|
|
37
|
+
...event.payload,
|
|
38
|
+
location: { directory: event.directory, workspaceID: event.workspace },
|
|
39
|
+
data: event.payload.properties,
|
|
40
|
+
})}\n\n`,
|
|
41
|
+
)
|
|
42
|
+
if (stream) return stream.enqueue(chunk)
|
|
43
|
+
pending.push(chunk)
|
|
44
|
+
},
|
|
45
|
+
response() {
|
|
46
|
+
return new Response(
|
|
47
|
+
new ReadableStream<Uint8Array>({
|
|
48
|
+
start(controller) {
|
|
49
|
+
stream = controller
|
|
50
|
+
for (const chunk of pending.splice(0)) controller.enqueue(chunk)
|
|
51
|
+
},
|
|
52
|
+
cancel() {
|
|
53
|
+
stream = undefined
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
{ headers: { "content-type": "text/event-stream" } },
|
|
57
|
+
)
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
|
|
63
|
+
|
|
64
|
+
export function createFetch(override?: FetchHandler, events?: ReturnType<typeof createEventSource>) {
|
|
65
|
+
const session = [] as URL[]
|
|
66
|
+
const fetch = (async (input: RequestInfo | URL) => {
|
|
67
|
+
const url = new URL(input instanceof Request ? input.url : String(input))
|
|
68
|
+
if (url.pathname === "/session") session.push(url)
|
|
69
|
+
const overridden = await override?.(url)
|
|
70
|
+
if (overridden) return overridden
|
|
71
|
+
if (url.pathname === "/api/event" && events) return events.response()
|
|
72
|
+
|
|
73
|
+
if (
|
|
74
|
+
[
|
|
75
|
+
"/agent",
|
|
76
|
+
"/command",
|
|
77
|
+
"/experimental/workspace",
|
|
78
|
+
"/experimental/workspace/status",
|
|
79
|
+
"/formatter",
|
|
80
|
+
"/lsp",
|
|
81
|
+
].includes(url.pathname)
|
|
82
|
+
)
|
|
83
|
+
return json([])
|
|
84
|
+
if (["/config", "/experimental/resource", "/mcp", "/provider/auth", "/session/status"].includes(url.pathname))
|
|
85
|
+
return json({})
|
|
86
|
+
if (url.pathname === "/config/providers") return json({ providers: {}, default: {} })
|
|
87
|
+
if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
|
|
88
|
+
if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: false })
|
|
89
|
+
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
|
|
90
|
+
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
|
|
91
|
+
if (
|
|
92
|
+
["/api/agent", "/api/model", "/api/provider", "/api/integration", "/api/command", "/api/skill"].includes(
|
|
93
|
+
url.pathname,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
return json({
|
|
97
|
+
location: { directory, project: { id: "proj_test", directory: worktree } },
|
|
98
|
+
data: [],
|
|
99
|
+
})
|
|
100
|
+
if (url.pathname === "/project/current") return json({ id: "proj_test" })
|
|
101
|
+
if (url.pathname === "/api/reference")
|
|
102
|
+
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
|
|
103
|
+
if (url.pathname === "/provider") return json({ all: [], default: {}, connected: [] })
|
|
104
|
+
if (url.pathname === "/session") return json([])
|
|
105
|
+
if (url.pathname === "/vcs") return json({ branch: "main" })
|
|
106
|
+
throw new Error(`unexpected request: ${url.pathname}`)
|
|
107
|
+
}) as typeof globalThis.fetch
|
|
108
|
+
return { fetch, session }
|
|
109
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
|
3
|
+
import { createBindingLookup } from "@opentui/keymap/extras"
|
|
4
|
+
import { testRender, useRenderer } from "@opentui/solid"
|
|
5
|
+
import { expect, test } from "bun:test"
|
|
6
|
+
import { onCleanup } from "solid-js"
|
|
7
|
+
import { TuiKeybind } from "../src/config/keybind"
|
|
8
|
+
import { getOpencodeModeStack, NEUROCODE_BASE_MODE, OpencodeKeymapProvider, registerOpencodeKeymap } from "../src/keymap"
|
|
9
|
+
|
|
10
|
+
function createResolvedKeymapConfig(input: TuiKeybind.KeybindOverrides = {}) {
|
|
11
|
+
const keybinds = TuiKeybind.parse(input)
|
|
12
|
+
return {
|
|
13
|
+
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
|
|
14
|
+
commandMap: TuiKeybind.CommandMap,
|
|
15
|
+
bindingDefaults: TuiKeybind.bindingDefaults(),
|
|
16
|
+
}),
|
|
17
|
+
leader_timeout: 2000,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test("legacy page key aliases compile as page keys", async () => {
|
|
22
|
+
const sequences: Record<string, string[][]> = {}
|
|
23
|
+
|
|
24
|
+
function Harness() {
|
|
25
|
+
const renderer = useRenderer()
|
|
26
|
+
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
27
|
+
const config = createResolvedKeymapConfig({
|
|
28
|
+
messages_page_up: "pgup",
|
|
29
|
+
messages_page_down: "pgdown",
|
|
30
|
+
})
|
|
31
|
+
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
|
32
|
+
const offLayer = keymap.registerLayer({
|
|
33
|
+
bindings: config.keybinds.gather("session", ["session.page.up", "session.page.down"]),
|
|
34
|
+
})
|
|
35
|
+
const bindings = keymap.getCommandBindings({
|
|
36
|
+
visibility: "registered",
|
|
37
|
+
commands: ["session.page.up", "session.page.down"],
|
|
38
|
+
})
|
|
39
|
+
sequences.up =
|
|
40
|
+
bindings.get("session.page.up")?.map((binding) => binding.sequence.map((part) => part.stroke.name)) ?? []
|
|
41
|
+
sequences.down =
|
|
42
|
+
bindings.get("session.page.down")?.map((binding) => binding.sequence.map((part) => part.stroke.name)) ?? []
|
|
43
|
+
onCleanup(() => {
|
|
44
|
+
offLayer()
|
|
45
|
+
offKeymap()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<OpencodeKeymapProvider keymap={keymap}>
|
|
50
|
+
<box />
|
|
51
|
+
</OpencodeKeymapProvider>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const app = await testRender(() => <Harness />)
|
|
56
|
+
try {
|
|
57
|
+
expect(sequences).toEqual({
|
|
58
|
+
up: [["pageup"]],
|
|
59
|
+
down: [["pagedown"]],
|
|
60
|
+
})
|
|
61
|
+
} finally {
|
|
62
|
+
app.renderer.destroy()
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test("mode-less bindings stay active when opencode mode changes", async () => {
|
|
67
|
+
const counts: Record<string, Record<string, number>> = {}
|
|
68
|
+
|
|
69
|
+
function Harness() {
|
|
70
|
+
const renderer = useRenderer()
|
|
71
|
+
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
72
|
+
const config = createResolvedKeymapConfig()
|
|
73
|
+
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
|
74
|
+
const offGlobal = keymap.registerLayer({
|
|
75
|
+
commands: [
|
|
76
|
+
{ name: "session.list", run() {} },
|
|
77
|
+
{ name: "session.new", run() {} },
|
|
78
|
+
{ name: "session.page.up", run() {} },
|
|
79
|
+
{ name: "session.first", run() {} },
|
|
80
|
+
],
|
|
81
|
+
bindings: config.keybinds.gather("test.global", [
|
|
82
|
+
"session.list",
|
|
83
|
+
"session.new",
|
|
84
|
+
"session.page.up",
|
|
85
|
+
"session.first",
|
|
86
|
+
]),
|
|
87
|
+
})
|
|
88
|
+
const offBase = keymap.registerLayer({
|
|
89
|
+
mode: NEUROCODE_BASE_MODE,
|
|
90
|
+
commands: [{ name: "model.list", run() {} }],
|
|
91
|
+
bindings: config.keybinds.gather("test.base", ["model.list"]),
|
|
92
|
+
})
|
|
93
|
+
const activeCounts = () =>
|
|
94
|
+
Object.fromEntries(
|
|
95
|
+
Array.from(
|
|
96
|
+
keymap.getCommandBindings({
|
|
97
|
+
visibility: "active",
|
|
98
|
+
commands: ["session.list", "session.new", "session.page.up", "session.first", "model.list"],
|
|
99
|
+
}),
|
|
100
|
+
([command, bindings]) => [command, bindings.length],
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
counts.base = activeCounts()
|
|
105
|
+
const popQuestion = getOpencodeModeStack(keymap).push("question")
|
|
106
|
+
counts.question = activeCounts()
|
|
107
|
+
popQuestion()
|
|
108
|
+
const popAutocomplete = getOpencodeModeStack(keymap).push("autocomplete")
|
|
109
|
+
counts.autocomplete = activeCounts()
|
|
110
|
+
popAutocomplete()
|
|
111
|
+
|
|
112
|
+
onCleanup(() => {
|
|
113
|
+
offBase()
|
|
114
|
+
offGlobal()
|
|
115
|
+
offKeymap()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<OpencodeKeymapProvider keymap={keymap}>
|
|
120
|
+
<box />
|
|
121
|
+
</OpencodeKeymapProvider>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const app = await testRender(() => <Harness />)
|
|
126
|
+
try {
|
|
127
|
+
expect(counts).toEqual({
|
|
128
|
+
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 },
|
|
129
|
+
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 },
|
|
130
|
+
autocomplete: {
|
|
131
|
+
"session.list": 1,
|
|
132
|
+
"session.new": 1,
|
|
133
|
+
"session.page.up": 2,
|
|
134
|
+
"session.first": 2,
|
|
135
|
+
"model.list": 0,
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
} finally {
|
|
139
|
+
app.renderer.destroy()
|
|
140
|
+
}
|
|
141
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { expect, test } from "bun:test"
|
|
2
|
+
import { createPluginRuntime } from "../../src/plugin/runtime"
|
|
3
|
+
|
|
4
|
+
test("routes use the latest registration and restore previous registrations", () => {
|
|
5
|
+
const runtime = createPluginRuntime()
|
|
6
|
+
const first = () => "first"
|
|
7
|
+
const second = () => "second"
|
|
8
|
+
runtime.routes.register([{ name: "demo", render: first }])
|
|
9
|
+
const dispose = runtime.routes.register([{ name: "demo", render: second }])
|
|
10
|
+
|
|
11
|
+
expect(runtime.routes.get("demo")).toBe(second)
|
|
12
|
+
dispose()
|
|
13
|
+
expect(runtime.routes.get("demo")).toBe(first)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("facade publishes and clears presentation state", async () => {
|
|
17
|
+
const runtime = createPluginRuntime()
|
|
18
|
+
runtime.update({
|
|
19
|
+
commands: {
|
|
20
|
+
async activate() {
|
|
21
|
+
return true
|
|
22
|
+
},
|
|
23
|
+
async deactivate() {
|
|
24
|
+
return true
|
|
25
|
+
},
|
|
26
|
+
async add() {
|
|
27
|
+
return true
|
|
28
|
+
},
|
|
29
|
+
async install() {
|
|
30
|
+
return { ok: true, dir: "/tmp", tui: true }
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
status: [
|
|
34
|
+
{
|
|
35
|
+
id: "demo",
|
|
36
|
+
source: "internal",
|
|
37
|
+
spec: "demo",
|
|
38
|
+
target: "demo",
|
|
39
|
+
enabled: true,
|
|
40
|
+
active: true,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
expect(await runtime.commands().activate("demo")).toBe(true)
|
|
46
|
+
expect(runtime.status()).toHaveLength(1)
|
|
47
|
+
runtime.clear()
|
|
48
|
+
expect(await runtime.commands().activate("demo")).toBe(false)
|
|
49
|
+
expect(runtime.status()).toEqual([])
|
|
50
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { expect, test } from "bun:test"
|
|
3
|
+
import { createSlot, createSolidSlotRegistry, testRender, useRenderer } from "@opentui/solid"
|
|
4
|
+
import { onMount } from "solid-js"
|
|
5
|
+
|
|
6
|
+
type Slots = {
|
|
7
|
+
prompt: {}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
test("replace slot mounts plugin content once", async () => {
|
|
11
|
+
let mounts = 0
|
|
12
|
+
|
|
13
|
+
const Probe = () => {
|
|
14
|
+
onMount(() => {
|
|
15
|
+
mounts += 1
|
|
16
|
+
})
|
|
17
|
+
return <box />
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const App = () => {
|
|
21
|
+
const registry = createSolidSlotRegistry<Slots>(useRenderer(), {})
|
|
22
|
+
const Slot = createSlot(registry)
|
|
23
|
+
registry.register({ id: "plugin", slots: { prompt: () => <Probe /> } })
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Slot name="prompt" mode="replace">
|
|
27
|
+
<box />
|
|
28
|
+
</Slot>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const app = await testRender(() => <App />)
|
|
33
|
+
try {
|
|
34
|
+
expect(mounts).toBe(1)
|
|
35
|
+
} finally {
|
|
36
|
+
app.renderer.destroy()
|
|
37
|
+
}
|
|
38
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { displayCharAt, displaySlice, mentionTriggerIndex } from "../../src/prompt/display"
|
|
3
|
+
|
|
4
|
+
describe("prompt display", () => {
|
|
5
|
+
test("uses display-width offsets for mentions", () => {
|
|
6
|
+
expect(mentionTriggerIndex("@")).toBe(0)
|
|
7
|
+
expect(mentionTriggerIndex("test @")).toBe(5)
|
|
8
|
+
expect(mentionTriggerIndex("中文 @")).toBe(5)
|
|
9
|
+
expect(mentionTriggerIndex("こんにちは @")).toBe(11)
|
|
10
|
+
expect(mentionTriggerIndex("한국어 @")).toBe(7)
|
|
11
|
+
expect(mentionTriggerIndex("🙂 @")).toBe(3)
|
|
12
|
+
expect(mentionTriggerIndex("中文 @src file", Bun.stringWidth("中文 @src"))).toBe(5)
|
|
13
|
+
expect(displayCharAt("中文 @src", Bun.stringWidth("中文 @"))).toBe("s")
|
|
14
|
+
expect(displaySlice("中文 @src", 5, Bun.stringWidth("中文 @src"))).toBe("@src")
|
|
15
|
+
expect(displaySlice("中文 @src", 6, Bun.stringWidth("中文 @src"))).toBe("src")
|
|
16
|
+
expect(mentionTriggerIndex("👨👩👧👦 @src", Bun.stringWidth("👨👩👧👦 @src"))).toBe(3)
|
|
17
|
+
expect(displayCharAt("👨👩👧👦 @src", Bun.stringWidth("👨👩👧👦 @"))).toBe("s")
|
|
18
|
+
expect(displaySlice("👨👩👧👦 @src", 3, Bun.stringWidth("👨👩👧👦 @src"))).toBe("@src")
|
|
19
|
+
expect(mentionTriggerIndex("@file1\n@file2", 13)).toBe(7)
|
|
20
|
+
expect(displayCharAt("@file1\n@file2", 6)).toBe("\n")
|
|
21
|
+
expect(displaySlice("@file1\n@file2", 8, 13)).toBe("file2")
|
|
22
|
+
expect(mentionTriggerIndex("@file1\nfoo @file2", 17)).toBe(11)
|
|
23
|
+
expect(mentionTriggerIndex("中文 @one\n@two", 14)).toBe(10)
|
|
24
|
+
expect(displaySlice("中文 @one\n@two", 11, 14)).toBe("two")
|
|
25
|
+
expect(mentionTriggerIndex("中文@")).toBeUndefined()
|
|
26
|
+
expect(mentionTriggerIndex("こんにちは@")).toBeUndefined()
|
|
27
|
+
expect(mentionTriggerIndex("한국어@")).toBeUndefined()
|
|
28
|
+
expect(mentionTriggerIndex("🙂@")).toBeUndefined()
|
|
29
|
+
expect(mentionTriggerIndex("hello@")).toBeUndefined()
|
|
30
|
+
expect(mentionTriggerIndex("foo@bar.com")).toBeUndefined()
|
|
31
|
+
expect(mentionTriggerIndex("中文 @src file")).toBeUndefined()
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { isDuplicateEntry, MAX_HISTORY_ENTRIES, parsePromptHistory, type PromptInfo } from "../../src/prompt/history"
|
|
3
|
+
|
|
4
|
+
const entry = (input: string, parts: PromptInfo["parts"] = []): PromptInfo => ({ input, parts })
|
|
5
|
+
|
|
6
|
+
describe("prompt history", () => {
|
|
7
|
+
test("recovers valid JSONL entries around corruption", () => {
|
|
8
|
+
expect(parsePromptHistory(`${JSON.stringify(entry("one"))}\nnot-json\n${JSON.stringify(entry("two"))}\n`)).toEqual([
|
|
9
|
+
entry("one"),
|
|
10
|
+
entry("two"),
|
|
11
|
+
])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test("retains only the newest entries", () => {
|
|
15
|
+
const input = Array.from({ length: MAX_HISTORY_ENTRIES + 5 }, (_, index) =>
|
|
16
|
+
JSON.stringify(entry(String(index))),
|
|
17
|
+
).join("\n")
|
|
18
|
+
const result = parsePromptHistory(input)
|
|
19
|
+
expect(result).toHaveLength(MAX_HISTORY_ENTRIES)
|
|
20
|
+
expect(result[0]?.input).toBe("5")
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("dedupes only identical consecutive entries", () => {
|
|
24
|
+
expect(isDuplicateEntry(undefined, entry("hello"))).toBe(false)
|
|
25
|
+
expect(isDuplicateEntry(entry("hello"), entry("hello"))).toBe(true)
|
|
26
|
+
expect(isDuplicateEntry(entry("foo"), entry("bar"))).toBe(false)
|
|
27
|
+
expect(isDuplicateEntry({ ...entry("ls"), mode: "normal" }, { ...entry("ls"), mode: "shell" })).toBe(false)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("does not dedupe entries with different parts", () => {
|
|
31
|
+
const a = entry("describe this", [
|
|
32
|
+
{ type: "file", mime: "image/png", filename: "a.png", url: "data:image/png;base64,AAA" },
|
|
33
|
+
])
|
|
34
|
+
const b = entry("describe this", [
|
|
35
|
+
{ type: "file", mime: "image/png", filename: "b.png", url: "data:image/png;base64,BBB" },
|
|
36
|
+
])
|
|
37
|
+
expect(isDuplicateEntry(a, b)).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { expect, test } from "bun:test"
|
|
2
|
+
import { MAX_FRECENCY_ENTRIES, parseFrecency } from "../../src/prompt/frecency"
|
|
3
|
+
import { MAX_STASH_ENTRIES, parsePromptStash } from "../../src/prompt/stash"
|
|
4
|
+
|
|
5
|
+
test("stash JSONL skips corruption and retains newest entries", () => {
|
|
6
|
+
const entries = Array.from({ length: MAX_STASH_ENTRIES + 2 }, (_, index) =>
|
|
7
|
+
JSON.stringify({ input: String(index), parts: [], timestamp: index }),
|
|
8
|
+
)
|
|
9
|
+
entries.splice(2, 0, "broken")
|
|
10
|
+
const result = parsePromptStash(entries.join("\n"))
|
|
11
|
+
expect(result).toHaveLength(MAX_STASH_ENTRIES)
|
|
12
|
+
expect(result[0]?.input).toBe("2")
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test("frecency JSONL skips corruption, keeps latest path state, and limits entries", () => {
|
|
16
|
+
const entries = Array.from({ length: MAX_FRECENCY_ENTRIES + 1 }, (_, index) =>
|
|
17
|
+
JSON.stringify({ path: String(index), frequency: 1, lastOpen: index }),
|
|
18
|
+
)
|
|
19
|
+
entries.push("broken", JSON.stringify({ path: "1000", frequency: 2, lastOpen: 2000 }))
|
|
20
|
+
const result = parseFrecency(entries.join("\n"))
|
|
21
|
+
expect(result).toHaveLength(MAX_FRECENCY_ENTRIES)
|
|
22
|
+
expect(result[0]).toEqual({ path: "1000", frequency: 2, lastOpen: 2000 })
|
|
23
|
+
expect(result.some((entry) => entry.path === "0")).toBe(false)
|
|
24
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
|
|
3
|
+
import type { LocalFiles } from "../../src/component/prompt/local-attachment"
|
|
4
|
+
|
|
5
|
+
function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles {
|
|
6
|
+
return {
|
|
7
|
+
mime: async () => input.mime,
|
|
8
|
+
readText: async () => input.text ?? "",
|
|
9
|
+
readBytes: async () => input.bytes ?? new Uint8Array(),
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("prompt local attachments", () => {
|
|
14
|
+
test("reads SVG attachments as text", async () => {
|
|
15
|
+
expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({
|
|
16
|
+
type: "text",
|
|
17
|
+
mime: "image/svg+xml",
|
|
18
|
+
content: "<svg />",
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test("reads image and PDF attachments as bytes", async () => {
|
|
23
|
+
const content = new Uint8Array([1, 2, 3])
|
|
24
|
+
expect(await readLocalAttachmentWith(files({ mime: "application/pdf", bytes: content }), "/tmp/file.pdf")).toEqual({
|
|
25
|
+
type: "binary",
|
|
26
|
+
mime: "application/pdf",
|
|
27
|
+
content,
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("ignores unsupported and unreadable local files", async () => {
|
|
32
|
+
expect(await readLocalAttachmentWith(files({ mime: "text/plain" }), "/tmp/file.txt")).toBeUndefined()
|
|
33
|
+
expect(
|
|
34
|
+
await readLocalAttachmentWith(
|
|
35
|
+
{
|
|
36
|
+
...files({ mime: "image/png" }),
|
|
37
|
+
readBytes: async () => Promise.reject(new Error("missing")),
|
|
38
|
+
},
|
|
39
|
+
"/tmp/missing.png",
|
|
40
|
+
),
|
|
41
|
+
).toBeUndefined()
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { expandTrackedPastedText, stripPromptPartIDs } from "../../src/prompt/part"
|
|
3
|
+
|
|
4
|
+
describe("prompt part", () => {
|
|
5
|
+
test("strips persisted IDs from reused parts", () => {
|
|
6
|
+
expect(
|
|
7
|
+
stripPromptPartIDs({
|
|
8
|
+
id: "prt_old",
|
|
9
|
+
sessionID: "ses_old",
|
|
10
|
+
messageID: "msg_old",
|
|
11
|
+
type: "file" as const,
|
|
12
|
+
mime: "image/png",
|
|
13
|
+
filename: "tiny.png",
|
|
14
|
+
url: "data:image/png;base64,abc",
|
|
15
|
+
}),
|
|
16
|
+
).toEqual({
|
|
17
|
+
type: "file",
|
|
18
|
+
mime: "image/png",
|
|
19
|
+
filename: "tiny.png",
|
|
20
|
+
url: "data:image/png;base64,abc",
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("preserves wide characters around pasted text", () => {
|
|
25
|
+
const marker = "[Pasted ~3 lines]"
|
|
26
|
+
const prefix = "你好你好\n"
|
|
27
|
+
|
|
28
|
+
expect(
|
|
29
|
+
expandTrackedPastedText(prefix + marker + "\n阿斯顿法国红酒看来", [
|
|
30
|
+
{
|
|
31
|
+
start: Bun.stringWidth("你好你好") + 1,
|
|
32
|
+
end: Bun.stringWidth("你好你好") + 1 + Bun.stringWidth(marker),
|
|
33
|
+
text: "public:\n\tvoid ExecuteTask();\nprivate:",
|
|
34
|
+
},
|
|
35
|
+
]),
|
|
36
|
+
).toBe("你好你好\npublic:\n\tvoid ExecuteTask();\nprivate:\n阿斯顿法国红酒看来")
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test("only expands the tracked placeholder occurrence", () => {
|
|
40
|
+
const marker = "[Pasted ~3 lines]"
|
|
41
|
+
const prefix = `keep ${marker} then `
|
|
42
|
+
|
|
43
|
+
expect(
|
|
44
|
+
expandTrackedPastedText(prefix + marker + " tail", [
|
|
45
|
+
{
|
|
46
|
+
start: Bun.stringWidth(prefix),
|
|
47
|
+
end: Bun.stringWidth(prefix + marker),
|
|
48
|
+
text: "alpha\nbeta\ngamma",
|
|
49
|
+
},
|
|
50
|
+
]),
|
|
51
|
+
).toBe(`keep ${marker} then alpha\nbeta\ngamma tail`)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { expect, test } from "bun:test"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import { mkdtemp, rm } from "fs/promises"
|
|
4
|
+
import { tmpdir } from "os"
|
|
5
|
+
import { appendText, readJson, readText, writeJsonAtomic, writeText } from "../../src/util/persistence"
|
|
6
|
+
|
|
7
|
+
test("persistence creates parent directories and supports text, append, and JSON", async () => {
|
|
8
|
+
const root = await mkdtemp(path.join(tmpdir(), "opencode-tui-persistence-"))
|
|
9
|
+
try {
|
|
10
|
+
const textPath = path.join(root, "nested", "state.jsonl")
|
|
11
|
+
await writeText(textPath, "one\n")
|
|
12
|
+
await appendText(textPath, "two\n")
|
|
13
|
+
expect(await readText(textPath)).toBe("one\ntwo\n")
|
|
14
|
+
|
|
15
|
+
const jsonPath = path.join(root, "other", "state.json")
|
|
16
|
+
await writeJsonAtomic(jsonPath, { value: 1 })
|
|
17
|
+
expect(await readJson<{ value: number }>(jsonPath)).toEqual({ value: 1 })
|
|
18
|
+
await writeJsonAtomic(jsonPath, { value: 2 })
|
|
19
|
+
expect(await readJson<{ value: number }>(jsonPath)).toEqual({ value: 2 })
|
|
20
|
+
} finally {
|
|
21
|
+
await rm(root, { recursive: true, force: true })
|
|
22
|
+
}
|
|
23
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { computePromptTraits } from "../../src/prompt/traits"
|
|
3
|
+
|
|
4
|
+
describe("computePromptTraits", () => {
|
|
5
|
+
test("normal mode without autocomplete only captures tab", () => {
|
|
6
|
+
const traits = computePromptTraits({ mode: "normal", autocompleteVisible: false })
|
|
7
|
+
expect(traits.capture).toEqual(["tab"])
|
|
8
|
+
expect(traits.suspend).toBeUndefined()
|
|
9
|
+
expect(traits.status).toBeUndefined()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test("normal mode with autocomplete captures navigation keys", () => {
|
|
13
|
+
const traits = computePromptTraits({ mode: "normal", autocompleteVisible: true })
|
|
14
|
+
expect(traits.capture).toEqual(["escape", "navigate", "submit", "tab"])
|
|
15
|
+
expect(traits.suspend).toBeUndefined()
|
|
16
|
+
expect(traits.status).toBeUndefined()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("shell mode disables capture and labels the prompt without suspending", () => {
|
|
20
|
+
const traits = computePromptTraits({ mode: "shell", autocompleteVisible: false })
|
|
21
|
+
expect(traits.capture).toBeUndefined()
|
|
22
|
+
expect(traits.suspend).toBeUndefined()
|
|
23
|
+
expect(traits.status).toBe("SHELL")
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { expect, test } from "bun:test"
|
|
2
|
+
import { testRender } from "@opentui/solid"
|
|
3
|
+
import { abbreviateHome } from "../src/runtime"
|
|
4
|
+
import { TuiPathsProvider, useTuiPaths } from "../src/context/runtime"
|
|
5
|
+
|
|
6
|
+
test("abbreviates paths within home boundaries", () => {
|
|
7
|
+
expect(abbreviateHome("/home/test", "/home/test")).toBe("~")
|
|
8
|
+
expect(abbreviateHome("/home/test/project", "/home/test")).toBe("~/project")
|
|
9
|
+
expect(abbreviateHome("/home/tester/project", "/home/test")).toBe("/home/tester/project")
|
|
10
|
+
expect(abbreviateHome("/tmp/project", "/home/test")).toBe("/tmp/project")
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test("provides focused immutable runtime inputs", async () => {
|
|
14
|
+
let paths: ReturnType<typeof useTuiPaths>
|
|
15
|
+
|
|
16
|
+
function Runtime() {
|
|
17
|
+
paths = useTuiPaths()
|
|
18
|
+
return <text>{paths.cwd}</text>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const app = await testRender(
|
|
22
|
+
() => (
|
|
23
|
+
<TuiPathsProvider value={{ cwd: "/work", home: "/home/test", state: "/state", worktree: "/worktree" }}>
|
|
24
|
+
<Runtime />
|
|
25
|
+
</TuiPathsProvider>
|
|
26
|
+
),
|
|
27
|
+
{ width: 40, height: 3 },
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await app.renderOnce()
|
|
32
|
+
expect(app.captureCharFrame()).toContain("/work")
|
|
33
|
+
expect(Object.isFrozen(paths!)).toBe(true)
|
|
34
|
+
} finally {
|
|
35
|
+
app.renderer.destroy()
|
|
36
|
+
}
|
|
37
|
+
})
|