@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,351 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { createSignal, For, Show } from "solid-js"
|
|
3
|
+
import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core"
|
|
4
|
+
import { testRender, type JSX } from "@opentui/solid"
|
|
5
|
+
import {
|
|
6
|
+
formatCompletedSubagentDetail,
|
|
7
|
+
formatSubagentRetry,
|
|
8
|
+
formatSubagentTitle,
|
|
9
|
+
formatSubagentToolcalls,
|
|
10
|
+
InlineToolRow,
|
|
11
|
+
parseApplyPatchFiles,
|
|
12
|
+
parseDiagnostics,
|
|
13
|
+
parseQuestionAnswers,
|
|
14
|
+
parseQuestions,
|
|
15
|
+
parseTodos,
|
|
16
|
+
alwaysSeparate,
|
|
17
|
+
toolDisplay,
|
|
18
|
+
} from "../../../src/routes/session"
|
|
19
|
+
|
|
20
|
+
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
testSetup?.renderer.destroy()
|
|
24
|
+
testSetup = undefined
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
type ToolFixture = { icon: string; label: string; error?: string }
|
|
28
|
+
|
|
29
|
+
const tools: readonly ToolFixture[] = [
|
|
30
|
+
{
|
|
31
|
+
icon: "✱",
|
|
32
|
+
label:
|
|
33
|
+
'Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.*dir|xdg|APPDATA" in packages/opencode/src (151 matches)',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
icon: "✱",
|
|
37
|
+
label: 'Glob "**/*db*" in packages/opencode (6 matches)',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
icon: "→",
|
|
41
|
+
label: "Read packages/opencode/src/storage/db.ts [offset=1, limit=130]",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
icon: "→",
|
|
45
|
+
label: "Read packages/opencode/src/index.ts [offset=1, limit=100]",
|
|
46
|
+
error: "No LSP server available for this file type.",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
icon: "✱",
|
|
50
|
+
label:
|
|
51
|
+
'Grep "export const NEUROCODE_DB|NEUROCODE_DB|NEUROCODE_DEV|Global\\.Path\\.data|data =" in packages/opencode/src (115 matches)',
|
|
52
|
+
},
|
|
53
|
+
] as const
|
|
54
|
+
|
|
55
|
+
function ShellOutput() {
|
|
56
|
+
return (
|
|
57
|
+
<box
|
|
58
|
+
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
|
|
59
|
+
marginTop={1}
|
|
60
|
+
paddingTop={1}
|
|
61
|
+
paddingBottom={1}
|
|
62
|
+
paddingLeft={2}
|
|
63
|
+
gap={1}
|
|
64
|
+
>
|
|
65
|
+
<box gap={1}>
|
|
66
|
+
<text>$ ls</text>
|
|
67
|
+
<text>file.ts</text>
|
|
68
|
+
</box>
|
|
69
|
+
</box>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function UserMessage() {
|
|
74
|
+
return (
|
|
75
|
+
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)}>
|
|
76
|
+
<box paddingTop={1} paddingBottom={1} paddingLeft={2}>
|
|
77
|
+
<text>Check whether the next tool remains separated.</text>
|
|
78
|
+
</box>
|
|
79
|
+
</box>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) {
|
|
84
|
+
return (
|
|
85
|
+
<box flexDirection="column" width={72}>
|
|
86
|
+
<box flexDirection="column">
|
|
87
|
+
{props.before === "shell" && <ShellOutput />}
|
|
88
|
+
{props.before === "user" && <UserMessage />}
|
|
89
|
+
<For each={tools}>
|
|
90
|
+
{(item) => (
|
|
91
|
+
<InlineToolRow
|
|
92
|
+
icon={item.icon}
|
|
93
|
+
complete={true}
|
|
94
|
+
pending=""
|
|
95
|
+
failed={Boolean(item.error)}
|
|
96
|
+
error={item.error}
|
|
97
|
+
errorExpanded={props.errorExpanded}
|
|
98
|
+
>
|
|
99
|
+
{item.label}
|
|
100
|
+
</InlineToolRow>
|
|
101
|
+
)}
|
|
102
|
+
</For>
|
|
103
|
+
</box>
|
|
104
|
+
</box>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function TaskRowsFixture() {
|
|
109
|
+
return (
|
|
110
|
+
<box flexDirection="column" width={72}>
|
|
111
|
+
<InlineToolRow icon="✱" complete={true} pending="">
|
|
112
|
+
Grep "Task" (2 matches)
|
|
113
|
+
</InlineToolRow>
|
|
114
|
+
<InlineToolRow icon="⠙" complete={true} pending="" separate={true}>
|
|
115
|
+
Explore Task — Inspect active task spacing
|
|
116
|
+
</InlineToolRow>
|
|
117
|
+
<InlineToolRow icon="✓" complete={true} pending="" separate={true}>
|
|
118
|
+
{"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"}
|
|
119
|
+
</InlineToolRow>
|
|
120
|
+
<InlineToolRow icon="→" complete={true} pending="">
|
|
121
|
+
Read src/cli/cmd/tui/routes/session/index.tsx
|
|
122
|
+
</InlineToolRow>
|
|
123
|
+
</box>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function LoadedReadBeforeTaskFixture() {
|
|
128
|
+
return (
|
|
129
|
+
<box flexDirection="column" width={72}>
|
|
130
|
+
<InlineToolRow icon="→" complete={true} pending="">
|
|
131
|
+
Read src/cli/cmd/tui/routes/session/index.tsx
|
|
132
|
+
</InlineToolRow>
|
|
133
|
+
<box paddingLeft={3}>
|
|
134
|
+
<text paddingLeft={3}>↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx</text>
|
|
135
|
+
</box>
|
|
136
|
+
<InlineToolRow icon="✓" complete={true} pending="" separate={true}>
|
|
137
|
+
{"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"}
|
|
138
|
+
</InlineToolRow>
|
|
139
|
+
</box>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function AssistantSummaryBeforeInlineFixture() {
|
|
144
|
+
return (
|
|
145
|
+
<box flexDirection="column" width={72}>
|
|
146
|
+
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)} paddingLeft={3}>
|
|
147
|
+
<text>▣ Build · Little Frank · 53.1s</text>
|
|
148
|
+
</box>
|
|
149
|
+
<InlineToolRow icon="✓" complete={true} pending="">
|
|
150
|
+
{"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"}
|
|
151
|
+
</InlineToolRow>
|
|
152
|
+
</box>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function AssistantErrorBeforeInlineFixture() {
|
|
157
|
+
return (
|
|
158
|
+
<box flexDirection="column" width={72}>
|
|
159
|
+
<box
|
|
160
|
+
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
|
|
161
|
+
border={["left"]}
|
|
162
|
+
paddingTop={1}
|
|
163
|
+
paddingBottom={1}
|
|
164
|
+
paddingLeft={2}
|
|
165
|
+
>
|
|
166
|
+
<text>Managed inference requires an active Member plan</text>
|
|
167
|
+
</box>
|
|
168
|
+
<InlineToolRow icon="✓" complete={true} pending="">
|
|
169
|
+
{"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"}
|
|
170
|
+
</InlineToolRow>
|
|
171
|
+
</box>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function StickyScrollFixture(props: { separated: boolean; scroll: (scroll: ScrollBoxRenderable) => void }) {
|
|
176
|
+
return (
|
|
177
|
+
<scrollbox ref={props.scroll} stickyScroll={true} stickyStart="bottom" height={3} width={72}>
|
|
178
|
+
<box height={1}>
|
|
179
|
+
<text>First row</text>
|
|
180
|
+
</box>
|
|
181
|
+
<box height={1}>
|
|
182
|
+
<text>Second row</text>
|
|
183
|
+
</box>
|
|
184
|
+
<Show when={props.separated}>
|
|
185
|
+
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)}>
|
|
186
|
+
<text>Assistant text</text>
|
|
187
|
+
</box>
|
|
188
|
+
</Show>
|
|
189
|
+
<InlineToolRow icon="→" complete={true} pending="">
|
|
190
|
+
Read src/cli/cmd/tui/routes/session/index.tsx
|
|
191
|
+
</InlineToolRow>
|
|
192
|
+
</scrollbox>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function FailedPendingToolFixture() {
|
|
197
|
+
return (
|
|
198
|
+
<InlineToolRow icon="%" complete={false} pending="Preparing patch..." failed={true} failure="Patch failed">
|
|
199
|
+
Patch
|
|
200
|
+
</InlineToolRow>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function FailedCompleteToolFixture() {
|
|
205
|
+
return (
|
|
206
|
+
<InlineToolRow icon="→" complete={true} pending="Reading file..." failed={true} failure="Read failed">
|
|
207
|
+
Read src/index.ts
|
|
208
|
+
</InlineToolRow>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
|
|
213
|
+
testSetup = await testRender(component, options)
|
|
214
|
+
await testSetup.renderOnce()
|
|
215
|
+
await testSetup.renderOnce()
|
|
216
|
+
|
|
217
|
+
return testSetup
|
|
218
|
+
.captureCharFrame()
|
|
219
|
+
.split("\n")
|
|
220
|
+
.map((line) => line.trimEnd())
|
|
221
|
+
.join("\n")
|
|
222
|
+
.trimEnd()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
describe("TUI inline tool wrapping", () => {
|
|
226
|
+
test("falls back for unknown tool names", () => {
|
|
227
|
+
expect(toolDisplay("bash")).toBe("bash")
|
|
228
|
+
expect(toolDisplay("plugin_tool")).toBe("generic")
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test("replaces pending copy when a tool fails before completion", async () => {
|
|
232
|
+
const frame = await renderFrame(() => <FailedPendingToolFixture />, { width: 72, height: 3 })
|
|
233
|
+
expect(frame).toContain("Patch failed")
|
|
234
|
+
expect(frame).not.toContain("Preparing patch")
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test("preserves useful completed copy when a tool fails", async () => {
|
|
238
|
+
const frame = await renderFrame(() => <FailedCompleteToolFixture />, { width: 72, height: 3 })
|
|
239
|
+
expect(frame).toContain("Read src/index.ts")
|
|
240
|
+
expect(frame).not.toContain("Read failed")
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test("filters malformed nested tool wire data", () => {
|
|
244
|
+
expect(
|
|
245
|
+
parseApplyPatchFiles([
|
|
246
|
+
null,
|
|
247
|
+
{ type: "add" },
|
|
248
|
+
{ type: "add", relativePath: "a.ts", filePath: "a.ts", patch: "diff", deletions: 0 },
|
|
249
|
+
]),
|
|
250
|
+
).toEqual([
|
|
251
|
+
{ type: "add", relativePath: "a.ts", filePath: "a.ts", patch: "diff", deletions: 0, movePath: undefined },
|
|
252
|
+
])
|
|
253
|
+
expect(parseTodos([null, { status: "pending" }, { status: "pending", content: "Safe" }])).toEqual([
|
|
254
|
+
{ status: "pending", content: "Safe" },
|
|
255
|
+
])
|
|
256
|
+
expect(parseQuestions([{}, { question: 1 }, { question: "Continue?" }])).toEqual([{ question: "Continue?" }])
|
|
257
|
+
expect(parseQuestionAnswers([null, ["yes", 1], "no"])).toEqual([[], ["yes"], []])
|
|
258
|
+
expect(parseQuestionAnswers({})).toBeUndefined()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test("ignores diagnostics with malformed nested ranges", () => {
|
|
262
|
+
expect(
|
|
263
|
+
parseDiagnostics(
|
|
264
|
+
{
|
|
265
|
+
"a.ts": [
|
|
266
|
+
{ severity: 1, message: "missing range" },
|
|
267
|
+
{ severity: 1, message: "bad line", range: { start: { line: "0", character: 1 } } },
|
|
268
|
+
{ severity: 1, message: "valid", range: { start: { line: 2, character: 3 } } },
|
|
269
|
+
],
|
|
270
|
+
},
|
|
271
|
+
"a.ts",
|
|
272
|
+
),
|
|
273
|
+
).toEqual([{ message: "valid", range: { start: { line: 2, character: 3 } } }])
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test("formats completed subagent toolcall details", () => {
|
|
277
|
+
expect(formatCompletedSubagentDetail(0, "501ms")).toBe("501ms")
|
|
278
|
+
expect(formatCompletedSubagentDetail(1, "501ms")).toBe("1 toolcall · 501ms")
|
|
279
|
+
expect(formatCompletedSubagentDetail(2, "501ms")).toBe("2 toolcalls · 501ms")
|
|
280
|
+
expect(formatSubagentToolcalls(0)).toBe("0 toolcalls")
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test("keeps background state attached to the subagent identity", () => {
|
|
284
|
+
expect(formatSubagentTitle("Explore", "Inspect renderer", false)).toBe("Explore Task — Inspect renderer")
|
|
285
|
+
expect(formatSubagentTitle("Explore", "Inspect renderer", true)).toBe(
|
|
286
|
+
"Explore Task (background) — Inspect renderer",
|
|
287
|
+
)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test("keeps retry status ahead of wrapping messages", () => {
|
|
291
|
+
expect(formatSubagentRetry(2, "Rate limited by provider")).toBe("Retrying (attempt 2) · Rate limited by provider")
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => {
|
|
295
|
+
expect(await renderFrame(() => <Fixture />, { width: 72, height: 12 })).toMatchSnapshot()
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test("snapshots expanded tool errors under the tool text", async () => {
|
|
299
|
+
expect(await renderFrame(() => <Fixture errorExpanded />, { width: 72, height: 12 })).toMatchSnapshot()
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test("keeps separation after a shell output block", async () => {
|
|
303
|
+
expect(await renderFrame(() => <Fixture before="shell" />, { width: 72, height: 16 })).toMatchSnapshot()
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test("keeps separation after a padded user message", async () => {
|
|
307
|
+
expect(await renderFrame(() => <Fixture before="user" />, { width: 72, height: 14 })).toMatchSnapshot()
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test("separates after a multi-line task row", async () => {
|
|
311
|
+
expect(await renderFrame(() => <TaskRowsFixture />, { width: 72, height: 10 })).toMatchSnapshot()
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
test("separates a task row from a preceding inline detail", async () => {
|
|
315
|
+
expect(await renderFrame(() => <LoadedReadBeforeTaskFixture />, { width: 72, height: 8 })).toMatchSnapshot()
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
test("separates an inline row from the previous assistant summary", async () => {
|
|
319
|
+
expect(await renderFrame(() => <AssistantSummaryBeforeInlineFixture />, { width: 72, height: 5 })).toMatchSnapshot()
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
test("separates an inline row from the previous assistant error", async () => {
|
|
323
|
+
expect(await renderFrame(() => <AssistantErrorBeforeInlineFixture />, { width: 72, height: 7 })).toMatchSnapshot()
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test("updates sticky-bottom geometry when a text separator mounts and unmounts", async () => {
|
|
327
|
+
const [separated, setSeparated] = createSignal(false)
|
|
328
|
+
let scroll: ScrollBoxRenderable | undefined
|
|
329
|
+
testSetup = await testRender(
|
|
330
|
+
() => <StickyScrollFixture separated={separated()} scroll={(value) => (scroll = value)} />,
|
|
331
|
+
{
|
|
332
|
+
width: 72,
|
|
333
|
+
height: 3,
|
|
334
|
+
},
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
await testSetup.renderOnce()
|
|
338
|
+
expect(scroll?.scrollHeight).toBe(3)
|
|
339
|
+
expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
|
|
340
|
+
|
|
341
|
+
setSeparated(true)
|
|
342
|
+
await testSetup.renderOnce()
|
|
343
|
+
expect(scroll?.scrollHeight).toBe(5)
|
|
344
|
+
expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
|
|
345
|
+
|
|
346
|
+
setSeparated(false)
|
|
347
|
+
await testSetup.renderOnce()
|
|
348
|
+
expect(scroll?.scrollHeight).toBe(3)
|
|
349
|
+
expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
|
|
350
|
+
})
|
|
351
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
|
|
3
|
+
// Regression test for the prompt submit race in
|
|
4
|
+
// packages/tui/src/component/prompt/index.tsx (`submit`).
|
|
5
|
+
//
|
|
6
|
+
// Before the fix, two concurrent `submit()` calls (e.g. a double-pressed
|
|
7
|
+
// Enter, or the input's native onSubmit racing another dispatch) each
|
|
8
|
+
// passed the `if (!store.prompt.input) return false` guard, each
|
|
9
|
+
// `await sdk.client.session.create(...)`, and each only captured
|
|
10
|
+
// `inputText = store.prompt.input` AFTER that await. The first invocation
|
|
11
|
+
// finished, sent the prompt, and cleared the store; the second invocation,
|
|
12
|
+
// now past its await, read the cleared store and sent an empty prompt to a
|
|
13
|
+
// second freshly-created session - leaving an orphaned session with the
|
|
14
|
+
// user's actual text and a phantom session visible to the user containing
|
|
15
|
+
// only an assistant reply.
|
|
16
|
+
//
|
|
17
|
+
// `submitMirror` below has the exact shape of the production `submit()`
|
|
18
|
+
// after the fix: an in-flight `submitting` guard wraps the original body.
|
|
19
|
+
// Two concurrent invocations must result in exactly one submission carrying
|
|
20
|
+
// the user's text, with no empty-text submission.
|
|
21
|
+
|
|
22
|
+
type Store = { input: string }
|
|
23
|
+
|
|
24
|
+
type SubmitResult = { sessionID: string; text: string }
|
|
25
|
+
|
|
26
|
+
type Harness = {
|
|
27
|
+
store: Store
|
|
28
|
+
submissions: SubmitResult[]
|
|
29
|
+
createSession(): Promise<string>
|
|
30
|
+
sendPrompt(sessionID: string, text: string): Promise<void>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createHarness(opts: { sessionCreateDelayMs: number }): Harness {
|
|
34
|
+
let sessionCounter = 0
|
|
35
|
+
const submissions: SubmitResult[] = []
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
store: { input: "" },
|
|
39
|
+
submissions,
|
|
40
|
+
async createSession() {
|
|
41
|
+
sessionCounter += 1
|
|
42
|
+
const id = `ses_${sessionCounter}`
|
|
43
|
+
await Bun.sleep(opts.sessionCreateDelayMs)
|
|
44
|
+
return id
|
|
45
|
+
},
|
|
46
|
+
async sendPrompt(sessionID, text) {
|
|
47
|
+
submissions.push({ sessionID, text })
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createSubmit() {
|
|
53
|
+
let submitting = false
|
|
54
|
+
return async function submit(h: Harness) {
|
|
55
|
+
if (submitting) return false
|
|
56
|
+
submitting = true
|
|
57
|
+
try {
|
|
58
|
+
if (!h.store.input) return false
|
|
59
|
+
const sessionID = await h.createSession()
|
|
60
|
+
const inputText = h.store.input
|
|
61
|
+
await h.sendPrompt(sessionID, inputText)
|
|
62
|
+
h.store.input = ""
|
|
63
|
+
return true
|
|
64
|
+
} finally {
|
|
65
|
+
submitting = false
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("Prompt.submit race", () => {
|
|
71
|
+
test("concurrent submits must not lose the user's text", async () => {
|
|
72
|
+
const submit = createSubmit()
|
|
73
|
+
const h = createHarness({ sessionCreateDelayMs: 5 })
|
|
74
|
+
h.store.input = "Hello there."
|
|
75
|
+
|
|
76
|
+
// Two invocations back-to-back, mimicking a double-Enter.
|
|
77
|
+
await Promise.all([submit(h), submit(h)])
|
|
78
|
+
|
|
79
|
+
// Every submission that did make it through must carry the actual user
|
|
80
|
+
// text, and no submission may have an empty text payload.
|
|
81
|
+
expect(h.submissions.every((s) => s.text === "Hello there.")).toBe(true)
|
|
82
|
+
expect(h.submissions.some((s) => s.text === "")).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test("a sequential second submit after clear is a no-op, not a phantom session", async () => {
|
|
86
|
+
const submit = createSubmit()
|
|
87
|
+
const h = createHarness({ sessionCreateDelayMs: 1 })
|
|
88
|
+
h.store.input = "Hello there."
|
|
89
|
+
|
|
90
|
+
await submit(h)
|
|
91
|
+
// After the first submission completes, the store is cleared; a second
|
|
92
|
+
// Enter on an empty input must not create a phantom session.
|
|
93
|
+
await submit(h)
|
|
94
|
+
|
|
95
|
+
expect(h.submissions).toHaveLength(1)
|
|
96
|
+
expect(h.submissions[0].text).toBe("Hello there.")
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { reasoningSummary } from "../../../src/context/thinking"
|
|
3
|
+
|
|
4
|
+
describe("reasoningSummary", () => {
|
|
5
|
+
test("extracts a leading summary title and leaves markdown body", () => {
|
|
6
|
+
expect(reasoningSummary("**Continuing Quality Review**\n\nDetails.\n\n**Next section**\n\nMore.")).toEqual({
|
|
7
|
+
title: "Continuing Quality Review",
|
|
8
|
+
body: "Details.\n\n**Next section**\n\nMore.",
|
|
9
|
+
})
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test("extracts a completed title before its streamed body arrives", () => {
|
|
13
|
+
expect(reasoningSummary("**Continuing Quality Review**")).toEqual({
|
|
14
|
+
title: "Continuing Quality Review",
|
|
15
|
+
body: "",
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("preserves markdown-significant indentation in the extracted body", () => {
|
|
20
|
+
expect(reasoningSummary("**Continuing Quality Review**\n\n const value = true\n")).toEqual({
|
|
21
|
+
title: "Continuing Quality Review",
|
|
22
|
+
body: " const value = true",
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("does not consume ordinary leading bold content", () => {
|
|
27
|
+
expect(reasoningSummary("**Important:** keep this in the body.")).toEqual({
|
|
28
|
+
title: null,
|
|
29
|
+
body: "**Important:** keep this in the body.",
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("leaves content without a leading title in its body", () => {
|
|
34
|
+
expect(reasoningSummary("Details only.")).toEqual({ title: null, body: "Details only." })
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { describe, expect, test } from "bun:test"
|
|
3
|
+
import { testRender } from "@opentui/solid"
|
|
4
|
+
import type { Event, GlobalEvent } from "@neurocode-ai/sdk/v2"
|
|
5
|
+
import { onMount } from "solid-js"
|
|
6
|
+
import { ProjectProvider, useProject } from "../../../src/context/project"
|
|
7
|
+
import { SDKProvider } from "../../../src/context/sdk"
|
|
8
|
+
import { useEvent } from "../../../src/context/event"
|
|
9
|
+
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
|
|
10
|
+
import { TestTuiContexts } from "../../fixture/tui-environment"
|
|
11
|
+
|
|
12
|
+
const projectID = "proj_test"
|
|
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
|
+
function event(payload: Event, input: { directory: string; project?: string; workspace?: string }): GlobalEvent {
|
|
23
|
+
return {
|
|
24
|
+
directory: input.directory,
|
|
25
|
+
project: input.project,
|
|
26
|
+
workspace: input.workspace,
|
|
27
|
+
payload,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function vcs(branch: string): Event {
|
|
32
|
+
return {
|
|
33
|
+
id: `evt_vcs_${branch}`,
|
|
34
|
+
type: "vcs.branch.updated",
|
|
35
|
+
properties: {
|
|
36
|
+
branch,
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function update(version: string): Event {
|
|
42
|
+
return {
|
|
43
|
+
id: `evt_update_${version}`,
|
|
44
|
+
type: "installation.update-available",
|
|
45
|
+
properties: {
|
|
46
|
+
version,
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function mount() {
|
|
52
|
+
const events = createEventSource()
|
|
53
|
+
const calls = createFetch()
|
|
54
|
+
const seen: Event[] = []
|
|
55
|
+
const workspaces: Array<string | undefined> = []
|
|
56
|
+
let project!: ReturnType<typeof useProject>
|
|
57
|
+
let done!: () => void
|
|
58
|
+
const ready = new Promise<void>((resolve) => {
|
|
59
|
+
done = resolve
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const app = await testRender(() => (
|
|
63
|
+
<TestTuiContexts>
|
|
64
|
+
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
|
65
|
+
<ProjectProvider>
|
|
66
|
+
<Probe
|
|
67
|
+
onReady={async (ctx) => {
|
|
68
|
+
project = ctx.project
|
|
69
|
+
await project.sync()
|
|
70
|
+
done()
|
|
71
|
+
}}
|
|
72
|
+
seen={seen}
|
|
73
|
+
workspaces={workspaces}
|
|
74
|
+
/>
|
|
75
|
+
</ProjectProvider>
|
|
76
|
+
</SDKProvider>
|
|
77
|
+
</TestTuiContexts>
|
|
78
|
+
))
|
|
79
|
+
|
|
80
|
+
await ready
|
|
81
|
+
return { app, emit: events.emit, project, seen, workspaces }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function Probe(props: {
|
|
85
|
+
seen: Event[]
|
|
86
|
+
workspaces: Array<string | undefined>
|
|
87
|
+
onReady: (ctx: { project: ReturnType<typeof useProject> }) => void
|
|
88
|
+
}) {
|
|
89
|
+
const project = useProject()
|
|
90
|
+
const event = useEvent()
|
|
91
|
+
|
|
92
|
+
onMount(() => {
|
|
93
|
+
event.subscribe((evt, { workspace }) => {
|
|
94
|
+
props.seen.push(evt)
|
|
95
|
+
props.workspaces.push(workspace)
|
|
96
|
+
})
|
|
97
|
+
props.onReady({ project })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return <box />
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe("useEvent", () => {
|
|
104
|
+
test("delivers events for the current project", async () => {
|
|
105
|
+
const { app, emit, seen, workspaces } = await mount()
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
emit(event(vcs("main"), { directory: "/tmp/other", project: projectID, workspace: "ws_a" }))
|
|
109
|
+
|
|
110
|
+
await wait(() => seen.length === 1)
|
|
111
|
+
|
|
112
|
+
expect(seen).toEqual([vcs("main")])
|
|
113
|
+
expect(workspaces).toEqual(["ws_a"])
|
|
114
|
+
} finally {
|
|
115
|
+
app.renderer.destroy()
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test("delivers current project events regardless of active workspace", async () => {
|
|
120
|
+
const { app, emit, project, seen } = await mount()
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
project.workspace.set("ws_a")
|
|
124
|
+
emit(event(vcs("ws"), { directory: "/tmp/other", project: projectID, workspace: "ws_b" }))
|
|
125
|
+
|
|
126
|
+
await wait(() => seen.length === 1)
|
|
127
|
+
|
|
128
|
+
expect(seen).toEqual([vcs("ws")])
|
|
129
|
+
} finally {
|
|
130
|
+
app.renderer.destroy()
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test("delivers truly global events even when a workspace is active", async () => {
|
|
135
|
+
const { app, emit, project, seen } = await mount()
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
project.workspace.set("ws_a")
|
|
139
|
+
emit(event(update("1.2.3"), { directory: "global" }))
|
|
140
|
+
|
|
141
|
+
await wait(() => seen.length === 1)
|
|
142
|
+
|
|
143
|
+
expect(seen).toEqual([update("1.2.3")])
|
|
144
|
+
} finally {
|
|
145
|
+
app.renderer.destroy()
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { expect, test } from "bun:test"
|
|
2
|
+
import { copyCommand } from "../src/clipboard"
|
|
3
|
+
|
|
4
|
+
test("prefers Wayland clipboard when available", () => {
|
|
5
|
+
expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"])
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
test("uses osascript on macOS", () => {
|
|
9
|
+
expect(copyCommand("darwin", false, (name) => name === "osascript")).toEqual(["osascript"])
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test("falls back through X11 clipboard commands", () => {
|
|
13
|
+
expect(copyCommand("linux", true, (name) => name === "xclip")).toEqual(["xclip", "-selection", "clipboard"])
|
|
14
|
+
expect(copyCommand("linux", false, (name) => name === "xsel")).toEqual(["xsel", "--clipboard", "--input"])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test("returns undefined when native clipboard is unavailable", () => {
|
|
18
|
+
expect(copyCommand("linux", false, () => false)).toBeUndefined()
|
|
19
|
+
})
|