@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,147 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { TextareaRenderable } from "@opentui/core"
|
|
3
|
+
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
|
4
|
+
import { testRender, useRenderer } from "@opentui/solid"
|
|
5
|
+
import { expect, test } from "bun:test"
|
|
6
|
+
import { mkdir } from "node:fs/promises"
|
|
7
|
+
import path from "node:path"
|
|
8
|
+
import { onCleanup } from "solid-js"
|
|
9
|
+
import { tmpdir } from "../../fixture/fixture"
|
|
10
|
+
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
|
11
|
+
import type { TuiKeybind } from "../../../src/config/keybind"
|
|
12
|
+
import { TestTuiContexts } from "../../fixture/tui-environment"
|
|
13
|
+
|
|
14
|
+
async function wait(fn: () => boolean, timeout = 2000) {
|
|
15
|
+
const start = Date.now()
|
|
16
|
+
while (!fn()) {
|
|
17
|
+
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
|
18
|
+
await Bun.sleep(10)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function mountPrompt(input: {
|
|
23
|
+
root: string
|
|
24
|
+
keybinds: Partial<TuiKeybind.Keybinds>
|
|
25
|
+
onConfirm: (value: string) => void
|
|
26
|
+
}) {
|
|
27
|
+
const state = path.join(input.root, "state")
|
|
28
|
+
await mkdir(state, { recursive: true })
|
|
29
|
+
await Bun.write(path.join(state, "kv.json"), "{}")
|
|
30
|
+
|
|
31
|
+
const [
|
|
32
|
+
{ DialogProvider },
|
|
33
|
+
{ DialogPrompt },
|
|
34
|
+
{ KVProvider },
|
|
35
|
+
{ ThemeProvider },
|
|
36
|
+
{ TuiConfigProvider },
|
|
37
|
+
{ ToastProvider },
|
|
38
|
+
{ OpencodeKeymapProvider, registerOpencodeKeymap },
|
|
39
|
+
] = await Promise.all([
|
|
40
|
+
import("../../../src/ui/dialog"),
|
|
41
|
+
import("../../../src/ui/dialog-prompt"),
|
|
42
|
+
import("../../../src/context/kv"),
|
|
43
|
+
import("../../../src/context/theme"),
|
|
44
|
+
import("../../../src/config"),
|
|
45
|
+
import("../../../src/ui/toast"),
|
|
46
|
+
import("../../../src/keymap"),
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
function Harness() {
|
|
50
|
+
const renderer = useRenderer()
|
|
51
|
+
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
52
|
+
const resolvedConfig = createTuiResolvedConfig({
|
|
53
|
+
keybinds: input.keybinds,
|
|
54
|
+
leader_timeout: 1000,
|
|
55
|
+
})
|
|
56
|
+
const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig)
|
|
57
|
+
onCleanup(off)
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<TestTuiContexts
|
|
61
|
+
directory={input.root}
|
|
62
|
+
paths={{
|
|
63
|
+
home: input.root,
|
|
64
|
+
state,
|
|
65
|
+
worktree: input.root,
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<OpencodeKeymapProvider keymap={keymap}>
|
|
69
|
+
<TuiConfigProvider config={resolvedConfig}>
|
|
70
|
+
<KVProvider>
|
|
71
|
+
<ThemeProvider mode="dark">
|
|
72
|
+
<ToastProvider>
|
|
73
|
+
<DialogProvider>
|
|
74
|
+
<DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
|
|
75
|
+
</DialogProvider>
|
|
76
|
+
</ToastProvider>
|
|
77
|
+
</ThemeProvider>
|
|
78
|
+
</KVProvider>
|
|
79
|
+
</TuiConfigProvider>
|
|
80
|
+
</OpencodeKeymapProvider>
|
|
81
|
+
</TestTuiContexts>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const app = await testRender(() => <Harness />, { kittyKeyboard: true })
|
|
86
|
+
return {
|
|
87
|
+
app,
|
|
88
|
+
async cleanup() {
|
|
89
|
+
app.renderer.destroy()
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
test("dialog prompt submit wins when return is also input newline", async () => {
|
|
95
|
+
await using tmp = await tmpdir()
|
|
96
|
+
const confirmed: string[] = []
|
|
97
|
+
const prompt = await mountPrompt({
|
|
98
|
+
root: tmp.path,
|
|
99
|
+
keybinds: {
|
|
100
|
+
input_submit: "super+return",
|
|
101
|
+
input_newline: "return,shift+return,alt+return,ctrl+j",
|
|
102
|
+
},
|
|
103
|
+
onConfirm: (value) => confirmed.push(value),
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
|
|
108
|
+
const textarea = prompt.app.renderer.currentFocusedEditor
|
|
109
|
+
if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
|
|
110
|
+
|
|
111
|
+
prompt.app.mockInput.pressEnter()
|
|
112
|
+
|
|
113
|
+
expect(confirmed).toEqual(["draft"])
|
|
114
|
+
expect(textarea.plainText).toBe("draft")
|
|
115
|
+
} finally {
|
|
116
|
+
await prompt.cleanup()
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test("dialog prompt submit can be rebound separately from input submit", async () => {
|
|
121
|
+
await using tmp = await tmpdir()
|
|
122
|
+
const confirmed: string[] = []
|
|
123
|
+
const prompt = await mountPrompt({
|
|
124
|
+
root: tmp.path,
|
|
125
|
+
keybinds: {
|
|
126
|
+
input_submit: "return",
|
|
127
|
+
"dialog.prompt.submit": "ctrl+y",
|
|
128
|
+
},
|
|
129
|
+
onConfirm: (value) => confirmed.push(value),
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
|
|
134
|
+
const textarea = prompt.app.renderer.currentFocusedEditor
|
|
135
|
+
if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
|
|
136
|
+
|
|
137
|
+
prompt.app.mockInput.pressEnter()
|
|
138
|
+
expect(confirmed).toEqual([])
|
|
139
|
+
expect(textarea.plainText).toBe("draft")
|
|
140
|
+
|
|
141
|
+
prompt.app.mockInput.pressKey("y", { ctrl: true })
|
|
142
|
+
|
|
143
|
+
expect(confirmed).toEqual(["draft"])
|
|
144
|
+
} finally {
|
|
145
|
+
await prompt.cleanup()
|
|
146
|
+
}
|
|
147
|
+
})
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { describe, expect, test } from "bun:test"
|
|
3
|
+
import { RGBA } from "@opentui/core"
|
|
4
|
+
import { testRender } from "@opentui/solid"
|
|
5
|
+
import type { JSX } from "solid-js"
|
|
6
|
+
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
|
7
|
+
import { KVProvider } from "../../../src/context/kv"
|
|
8
|
+
import { ThemeProvider } from "../../../src/context/theme"
|
|
9
|
+
import { TuiConfigProvider } from "../../../src/config"
|
|
10
|
+
import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree"
|
|
11
|
+
import { TestTuiContexts } from "../../fixture/tui-environment"
|
|
12
|
+
import {
|
|
13
|
+
allExpandedFileTreeDirectories,
|
|
14
|
+
buildFileTree,
|
|
15
|
+
} from "../../../src/feature-plugins/system/diff-viewer-file-tree-utils"
|
|
16
|
+
|
|
17
|
+
const theme = {
|
|
18
|
+
background: RGBA.fromHex("#000000"),
|
|
19
|
+
backgroundPanel: RGBA.fromHex("#111111"),
|
|
20
|
+
backgroundElement: RGBA.fromHex("#333333"),
|
|
21
|
+
primary: RGBA.fromHex("#00ffff"),
|
|
22
|
+
secondary: RGBA.fromHex("#0088ff"),
|
|
23
|
+
selectedListItemText: RGBA.fromHex("#ffffff"),
|
|
24
|
+
text: RGBA.fromHex("#ffffff"),
|
|
25
|
+
textMuted: RGBA.fromHex("#888888"),
|
|
26
|
+
error: RGBA.fromHex("#ff0000"),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("DiffViewerFileTree", () => {
|
|
30
|
+
test.skip("renders sorted hierarchical file rows", async () => {
|
|
31
|
+
const app = await testRender(
|
|
32
|
+
() =>
|
|
33
|
+
withTheme(() => (
|
|
34
|
+
<DiffViewerFileTree
|
|
35
|
+
width={32}
|
|
36
|
+
files={[
|
|
37
|
+
{ file: "z-file.ts" },
|
|
38
|
+
{ file: "b/file.ts" },
|
|
39
|
+
{ file: "a/zeta.ts" },
|
|
40
|
+
{ file: "b/alpha.ts" },
|
|
41
|
+
{ file: "a/alpha.ts" },
|
|
42
|
+
]}
|
|
43
|
+
loading={false}
|
|
44
|
+
error={undefined}
|
|
45
|
+
theme={theme}
|
|
46
|
+
focused={true}
|
|
47
|
+
/>
|
|
48
|
+
)),
|
|
49
|
+
{ width: 40, height: 20 },
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await renderOnceSettled(app)
|
|
54
|
+
const lines = visibleLines(app.captureCharFrame())
|
|
55
|
+
|
|
56
|
+
expect(lines).toEqual([
|
|
57
|
+
"▾ a",
|
|
58
|
+
"│ ├─ alpha.ts ?",
|
|
59
|
+
"│ └─ zeta.ts ?",
|
|
60
|
+
"├─ ▾ b",
|
|
61
|
+
"│ ├─ alpha.ts ?",
|
|
62
|
+
"│ └─ file.ts ?",
|
|
63
|
+
])
|
|
64
|
+
} finally {
|
|
65
|
+
app.renderer.destroy()
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("keeps loading and error quiet while rendering an empty settled state", async () => {
|
|
70
|
+
const loading = await renderFrame(() => (
|
|
71
|
+
<DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} theme={theme} />
|
|
72
|
+
))
|
|
73
|
+
const failed = await renderFrame(() => (
|
|
74
|
+
<DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} theme={theme} />
|
|
75
|
+
))
|
|
76
|
+
const empty = await renderFrame(() => (
|
|
77
|
+
<DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} theme={theme} />
|
|
78
|
+
))
|
|
79
|
+
|
|
80
|
+
expect(loading).not.toContain("Loading diff...")
|
|
81
|
+
expect(loading).not.toContain("No files")
|
|
82
|
+
expect(failed).not.toContain("Failed to load diff")
|
|
83
|
+
expect(failed).not.toContain("No files")
|
|
84
|
+
expect(empty).toContain("No files")
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test("does not render text markers for highlighted rows", async () => {
|
|
88
|
+
const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }]
|
|
89
|
+
const src = buildFileTree(files).nodes.find((node) => node.kind === "directory" && node.name === "src")!
|
|
90
|
+
|
|
91
|
+
const focused = visibleLines(
|
|
92
|
+
await renderFrame(() => (
|
|
93
|
+
<DiffViewerFileTree
|
|
94
|
+
width={32}
|
|
95
|
+
files={files}
|
|
96
|
+
loading={false}
|
|
97
|
+
error={undefined}
|
|
98
|
+
theme={theme}
|
|
99
|
+
focused
|
|
100
|
+
highlightedNode={src.id}
|
|
101
|
+
/>
|
|
102
|
+
)),
|
|
103
|
+
)
|
|
104
|
+
const unfocused = visibleLines(
|
|
105
|
+
await renderFrame(() => (
|
|
106
|
+
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} />
|
|
107
|
+
)),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
expect(focused).toContain("▾ src/config")
|
|
111
|
+
expect(unfocused).toContain("▾ src/config")
|
|
112
|
+
expect(focused.some((line) => line.includes("*"))).toBe(false)
|
|
113
|
+
expect(unfocused.some((line) => line.includes("*"))).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test("renders collapsed and expanded directory rows", async () => {
|
|
117
|
+
const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }]
|
|
118
|
+
const tree = buildFileTree(files)
|
|
119
|
+
const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
|
|
120
|
+
const collapsed = allExpandedFileTreeDirectories(tree)
|
|
121
|
+
collapsed.delete(src.id)
|
|
122
|
+
|
|
123
|
+
expect(
|
|
124
|
+
visibleLines(
|
|
125
|
+
await renderFrame(() => (
|
|
126
|
+
<DiffViewerFileTree
|
|
127
|
+
width={32}
|
|
128
|
+
files={files}
|
|
129
|
+
loading={false}
|
|
130
|
+
error={undefined}
|
|
131
|
+
theme={theme}
|
|
132
|
+
expandedNodes={collapsed}
|
|
133
|
+
/>
|
|
134
|
+
)),
|
|
135
|
+
),
|
|
136
|
+
).toEqual(["▸ src/config"])
|
|
137
|
+
|
|
138
|
+
expect(
|
|
139
|
+
visibleLines(
|
|
140
|
+
await renderFrame(() => (
|
|
141
|
+
<DiffViewerFileTree
|
|
142
|
+
files={files}
|
|
143
|
+
width={32}
|
|
144
|
+
loading={false}
|
|
145
|
+
error={undefined}
|
|
146
|
+
theme={theme}
|
|
147
|
+
expandedNodes={allExpandedFileTreeDirectories(tree)}
|
|
148
|
+
/>
|
|
149
|
+
)),
|
|
150
|
+
),
|
|
151
|
+
).toEqual(["▾ src/config", "│ └─ tui.ts ?"])
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
async function renderFrame(component: () => JSX.Element) {
|
|
156
|
+
const app = await testRender(() => withTheme(component), { width: 40, height: 10 })
|
|
157
|
+
try {
|
|
158
|
+
await renderOnceSettled(app)
|
|
159
|
+
return await captureSettledFrame(app)
|
|
160
|
+
} finally {
|
|
161
|
+
app.renderer.destroy()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function renderOnceSettled(app: Awaited<ReturnType<typeof testRender>>) {
|
|
166
|
+
await app.renderOnce()
|
|
167
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
168
|
+
await app.renderOnce()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function captureSettledFrame(app: Awaited<ReturnType<typeof testRender>>) {
|
|
172
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
173
|
+
const frame = app.captureCharFrame()
|
|
174
|
+
if (frame.trim().length > 0) return frame
|
|
175
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
176
|
+
await app.renderOnce()
|
|
177
|
+
}
|
|
178
|
+
return app.captureCharFrame()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function withTheme(component: () => JSX.Element) {
|
|
182
|
+
return (
|
|
183
|
+
<TestTuiContexts>
|
|
184
|
+
<TuiConfigProvider config={createTuiResolvedConfig()}>
|
|
185
|
+
<KVProvider>
|
|
186
|
+
<ThemeProvider mode="dark">{component()}</ThemeProvider>
|
|
187
|
+
</KVProvider>
|
|
188
|
+
</TuiConfigProvider>
|
|
189
|
+
</TestTuiContexts>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function visibleLines(frame: string) {
|
|
194
|
+
return frame
|
|
195
|
+
.split("\n")
|
|
196
|
+
.map((line) => line.trimEnd())
|
|
197
|
+
.map((line) => line.replace(/^ ?│ ?/, "").replace(/[ │]*$/, ""))
|
|
198
|
+
.map((line) => (line.startsWith(" ") ? line.slice(1) : line))
|
|
199
|
+
.filter((line) => line.length > 0 && !/^┌|^└|^─+$/.test(line))
|
|
200
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { expect, test } from "bun:test"
|
|
3
|
+
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
|
4
|
+
import { DiffRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/core"
|
|
5
|
+
import { testRender, useRenderer } from "@opentui/solid"
|
|
6
|
+
import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@neurocode-ai/plugin/tui"
|
|
7
|
+
import type { Session } from "@neurocode-ai/sdk/v2"
|
|
8
|
+
import { KVProvider } from "../../../src/context/kv"
|
|
9
|
+
import { ThemeProvider } from "../../../src/context/theme"
|
|
10
|
+
import { TuiConfigProvider } from "../../../src/config"
|
|
11
|
+
import { TuiKeybind } from "../../../src/config/keybind"
|
|
12
|
+
import { OpencodeKeymapProvider } from "../../../src/keymap"
|
|
13
|
+
import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer"
|
|
14
|
+
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
|
15
|
+
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
|
16
|
+
import { TestTuiContexts } from "../../fixture/tui-environment"
|
|
17
|
+
|
|
18
|
+
test("closing the diff viewer returns to the route it opened from", async () => {
|
|
19
|
+
const viewer = await renderDiffViewer([])
|
|
20
|
+
try {
|
|
21
|
+
expect(viewer.current()).toEqual({
|
|
22
|
+
name: "diff",
|
|
23
|
+
params: { mode: "git", sessionID: "session-1", returnRoute: startRoute },
|
|
24
|
+
})
|
|
25
|
+
expect(viewer.vcsDiffInput()).toEqual({ directory: "/repo/session", mode: "git", context: 12 })
|
|
26
|
+
|
|
27
|
+
expect(viewer.commands.has("diff.close")).toBe(true)
|
|
28
|
+
viewer.commands.get("diff.close")!.run?.({} as never)
|
|
29
|
+
expect(viewer.current()).toEqual(startRoute)
|
|
30
|
+
} finally {
|
|
31
|
+
viewer.app.renderer.destroy()
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test("brackets navigate diff hunks", async () => {
|
|
36
|
+
const viewer = await renderDiffViewer(
|
|
37
|
+
[
|
|
38
|
+
{
|
|
39
|
+
file: "src/file.ts",
|
|
40
|
+
additions: 3,
|
|
41
|
+
deletions: 3,
|
|
42
|
+
status: "modified",
|
|
43
|
+
patch: `--- a/src/file.ts
|
|
44
|
+
+++ b/src/file.ts
|
|
45
|
+
@@ -1,3 +1,3 @@
|
|
46
|
+
const first = true
|
|
47
|
+
-const oldFirst = true
|
|
48
|
+
+const newFirst = true
|
|
49
|
+
const afterFirst = true
|
|
50
|
+
@@ -20,3 +20,3 @@
|
|
51
|
+
const second = true
|
|
52
|
+
-const oldSecond = true
|
|
53
|
+
+const newSecond = true
|
|
54
|
+
const afterSecond = true
|
|
55
|
+
@@ -40,3 +40,3 @@
|
|
56
|
+
const third = true
|
|
57
|
+
-const oldThird = true
|
|
58
|
+
+const newThird = true
|
|
59
|
+
const afterThird = true`,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
12,
|
|
63
|
+
)
|
|
64
|
+
try {
|
|
65
|
+
await viewer.app.waitForFrame((frame) => frame.includes("const first"))
|
|
66
|
+
await viewer.app.waitFor(() => Boolean(findScrollBox(viewer.app.renderer.root)))
|
|
67
|
+
await viewer.app.flush()
|
|
68
|
+
const scroll = findScrollBox(viewer.app.renderer.root)!
|
|
69
|
+
const initial = scroll.scrollTop
|
|
70
|
+
|
|
71
|
+
expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]")
|
|
72
|
+
expect(TuiKeybind.defaultValue("diff_previous_hunk")).toBe("[")
|
|
73
|
+
|
|
74
|
+
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
|
|
75
|
+
await viewer.app.renderOnce()
|
|
76
|
+
const first = scroll.scrollTop
|
|
77
|
+
expect(first).toBeGreaterThan(initial)
|
|
78
|
+
|
|
79
|
+
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
|
|
80
|
+
await viewer.app.renderOnce()
|
|
81
|
+
const second = scroll.scrollTop
|
|
82
|
+
expect(second).toBeGreaterThan(first)
|
|
83
|
+
|
|
84
|
+
viewer.commands.get("diff.previous_hunk")!.run?.({} as never)
|
|
85
|
+
await viewer.app.renderOnce()
|
|
86
|
+
expect(scroll.scrollTop).toBe(first)
|
|
87
|
+
|
|
88
|
+
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
|
|
89
|
+
await viewer.app.renderOnce()
|
|
90
|
+
expect(scroll.scrollTop).toBe(second)
|
|
91
|
+
|
|
92
|
+
scroll.scrollTo(initial)
|
|
93
|
+
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
|
|
94
|
+
await viewer.app.renderOnce()
|
|
95
|
+
expect(scroll.scrollTop).toBe(first)
|
|
96
|
+
} finally {
|
|
97
|
+
viewer.app.renderer.destroy()
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: TuiRouteCurrent) {
|
|
102
|
+
const commands = new Map<
|
|
103
|
+
string,
|
|
104
|
+
NonNullable<Parameters<TuiPluginApi["keymap"]["registerLayer"]>[0]["commands"]>[number]
|
|
105
|
+
>()
|
|
106
|
+
let current = initialRoute ?? startRoute
|
|
107
|
+
let renderDiff: TuiRouteDefinition["render"] | undefined
|
|
108
|
+
let vcsDiffInput: unknown
|
|
109
|
+
let sessionDiffInput: unknown
|
|
110
|
+
const config = createTuiResolvedConfig()
|
|
111
|
+
function Harness() {
|
|
112
|
+
const renderer = useRenderer()
|
|
113
|
+
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
114
|
+
const registerLayer = keymap.registerLayer.bind(keymap)
|
|
115
|
+
keymap.registerLayer = (layer) => {
|
|
116
|
+
layer.commands?.forEach((command) => commands.set(command.name, command))
|
|
117
|
+
return registerLayer(layer)
|
|
118
|
+
}
|
|
119
|
+
const base = createTuiPluginApi({
|
|
120
|
+
keymap,
|
|
121
|
+
client: {
|
|
122
|
+
vcs: {
|
|
123
|
+
diff: async (input: unknown) => {
|
|
124
|
+
vcsDiffInput = input
|
|
125
|
+
return { data: vcsDiff }
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
session: {
|
|
129
|
+
diff: async (input: unknown) => {
|
|
130
|
+
sessionDiffInput = input
|
|
131
|
+
return { data: [] }
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
} as unknown as TuiPluginApi["client"],
|
|
135
|
+
state: {
|
|
136
|
+
session: {
|
|
137
|
+
get: () => session,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
const api = {
|
|
142
|
+
...base,
|
|
143
|
+
route: {
|
|
144
|
+
register(routes) {
|
|
145
|
+
renderDiff = routes.find((route) => route.name === "diff")?.render
|
|
146
|
+
return () => {}
|
|
147
|
+
},
|
|
148
|
+
navigate(name, params) {
|
|
149
|
+
current = params ? { name, params } : { name }
|
|
150
|
+
},
|
|
151
|
+
get current() {
|
|
152
|
+
return current
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
} satisfies TuiPluginApi
|
|
156
|
+
|
|
157
|
+
void diffViewerPlugin.tui(api, undefined, pluginMeta)
|
|
158
|
+
if (!initialRoute) commands.get("diff.open")?.run?.({} as never)
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<TestTuiContexts>
|
|
162
|
+
<OpencodeKeymapProvider keymap={keymap}>
|
|
163
|
+
<TuiConfigProvider config={config}>
|
|
164
|
+
<KVProvider>
|
|
165
|
+
<ThemeProvider mode="dark">
|
|
166
|
+
{renderDiff?.({ params: "params" in current ? current.params : undefined })}
|
|
167
|
+
</ThemeProvider>
|
|
168
|
+
</KVProvider>
|
|
169
|
+
</TuiConfigProvider>
|
|
170
|
+
</OpencodeKeymapProvider>
|
|
171
|
+
</TestTuiContexts>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const app = await testRender(() => <Harness />, { width: 80, height })
|
|
176
|
+
await waitForCommand(app, commands, "diff.close")
|
|
177
|
+
return {
|
|
178
|
+
app,
|
|
179
|
+
commands,
|
|
180
|
+
current: () => current,
|
|
181
|
+
vcsDiffInput: () => vcsDiffInput,
|
|
182
|
+
sessionDiffInput: () => sessionDiffInput,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const startRoute: TuiRouteCurrent = { name: "session", params: { sessionID: "session-1" } }
|
|
187
|
+
|
|
188
|
+
function findScrollBox(root: Renderable): ScrollBoxRenderable | undefined {
|
|
189
|
+
if (root instanceof ScrollBoxRenderable && containsDiff(root)) return root
|
|
190
|
+
return root.getChildren().map(findScrollBox).find(Boolean)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function containsDiff(root: Renderable): boolean {
|
|
194
|
+
if (root instanceof DiffRenderable) return true
|
|
195
|
+
return root.getChildren().some(containsDiff)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const session = {
|
|
199
|
+
id: "session-1",
|
|
200
|
+
slug: "session-1",
|
|
201
|
+
projectID: "project-1",
|
|
202
|
+
directory: "/repo/session",
|
|
203
|
+
title: "Session",
|
|
204
|
+
version: "1",
|
|
205
|
+
time: {
|
|
206
|
+
created: 0,
|
|
207
|
+
updated: 0,
|
|
208
|
+
},
|
|
209
|
+
} satisfies Session
|
|
210
|
+
|
|
211
|
+
test("branch diff source requests branch VCS diff", async () => {
|
|
212
|
+
const viewer = await renderDiffViewer([], 20, {
|
|
213
|
+
name: "diff",
|
|
214
|
+
params: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
|
|
215
|
+
})
|
|
216
|
+
try {
|
|
217
|
+
expect(viewer.current()).toEqual({
|
|
218
|
+
name: "diff",
|
|
219
|
+
params: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
|
|
220
|
+
})
|
|
221
|
+
expect(viewer.vcsDiffInput()).toEqual({ directory: "/repo/session", mode: "branch", context: 12 })
|
|
222
|
+
expect(viewer.sessionDiffInput()).toBeUndefined()
|
|
223
|
+
} finally {
|
|
224
|
+
viewer.app.renderer.destroy()
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test("last-turn diff source requests session diff", async () => {
|
|
229
|
+
const viewer = await renderDiffViewer([], 20, {
|
|
230
|
+
name: "diff",
|
|
231
|
+
params: { mode: "last-turn", sessionID: "session-1", messageID: "message-1", returnRoute: startRoute },
|
|
232
|
+
})
|
|
233
|
+
try {
|
|
234
|
+
expect(viewer.current()).toEqual({
|
|
235
|
+
name: "diff",
|
|
236
|
+
params: { mode: "last-turn", sessionID: "session-1", messageID: "message-1", returnRoute: startRoute },
|
|
237
|
+
})
|
|
238
|
+
expect(viewer.sessionDiffInput()).toEqual({ sessionID: "session-1", messageID: "message-1" })
|
|
239
|
+
expect(viewer.vcsDiffInput()).toBeUndefined()
|
|
240
|
+
} finally {
|
|
241
|
+
viewer.app.renderer.destroy()
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
async function waitForCommand(
|
|
246
|
+
app: Awaited<ReturnType<typeof testRender>>,
|
|
247
|
+
commands: Map<string, unknown>,
|
|
248
|
+
command: string,
|
|
249
|
+
) {
|
|
250
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
251
|
+
await app.renderOnce()
|
|
252
|
+
if (commands.has(command)) return
|
|
253
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const pluginMeta = {
|
|
258
|
+
id: "diff-viewer",
|
|
259
|
+
source: "internal",
|
|
260
|
+
spec: "diff-viewer",
|
|
261
|
+
target: "diff-viewer",
|
|
262
|
+
first_time: 0,
|
|
263
|
+
last_time: 0,
|
|
264
|
+
time_changed: 0,
|
|
265
|
+
load_count: 1,
|
|
266
|
+
fingerprint: "test",
|
|
267
|
+
state: "same",
|
|
268
|
+
} satisfies TuiPluginMeta
|