@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.
Files changed (241) hide show
  1. package/bunfig.toml +4 -0
  2. package/package.json +72 -0
  3. package/src/app.tsx +1134 -0
  4. package/src/attention.ts +260 -0
  5. package/src/audio.d.ts +9 -0
  6. package/src/audio.ts +53 -0
  7. package/src/clipboard.ts +124 -0
  8. package/src/component/bg-pulse-render.ts +436 -0
  9. package/src/component/bg-pulse.tsx +99 -0
  10. package/src/component/command-palette.tsx +79 -0
  11. package/src/component/dialog-agent.tsx +31 -0
  12. package/src/component/dialog-console-org.tsx +135 -0
  13. package/src/component/dialog-debug.tsx +90 -0
  14. package/src/component/dialog-mcp.tsx +85 -0
  15. package/src/component/dialog-model.tsx +197 -0
  16. package/src/component/dialog-move-session.tsx +353 -0
  17. package/src/component/dialog-provider.tsx +469 -0
  18. package/src/component/dialog-retry-action.tsx +160 -0
  19. package/src/component/dialog-session-delete-failed.tsx +99 -0
  20. package/src/component/dialog-session-list.tsx +364 -0
  21. package/src/component/dialog-session-rename.tsx +31 -0
  22. package/src/component/dialog-skill.tsx +70 -0
  23. package/src/component/dialog-stash.tsx +87 -0
  24. package/src/component/dialog-status.tsx +168 -0
  25. package/src/component/dialog-tag.tsx +47 -0
  26. package/src/component/dialog-theme-list.tsx +50 -0
  27. package/src/component/dialog-variant.tsx +39 -0
  28. package/src/component/dialog-workspace-create.tsx +308 -0
  29. package/src/component/dialog-workspace-file-changes.tsx +144 -0
  30. package/src/component/dialog-workspace-list.tsx +112 -0
  31. package/src/component/dialog-workspace-unavailable.tsx +69 -0
  32. package/src/component/error-component.tsx +240 -0
  33. package/src/component/logo.tsx +61 -0
  34. package/src/component/plugin-route-missing.tsx +14 -0
  35. package/src/component/prompt/autocomplete.tsx +781 -0
  36. package/src/component/prompt/cwd.ts +0 -0
  37. package/src/component/prompt/frecency.tsx +1 -0
  38. package/src/component/prompt/history.tsx +1 -0
  39. package/src/component/prompt/index.tsx +1713 -0
  40. package/src/component/prompt/local-attachment.ts +48 -0
  41. package/src/component/prompt/move.tsx +205 -0
  42. package/src/component/prompt/stash.tsx +1 -0
  43. package/src/component/prompt/workspace.tsx +137 -0
  44. package/src/component/register-spinner.ts +6 -0
  45. package/src/component/spinner.tsx +26 -0
  46. package/src/component/startup-loading.tsx +63 -0
  47. package/src/component/todo-item.tsx +32 -0
  48. package/src/component/use-connected.tsx +12 -0
  49. package/src/component/workspace-label.tsx +19 -0
  50. package/src/config/index.tsx +129 -0
  51. package/src/config/keybind.ts +471 -0
  52. package/src/context/args.tsx +16 -0
  53. package/src/context/clipboard.tsx +18 -0
  54. package/src/context/data.tsx +569 -0
  55. package/src/context/directory.ts +17 -0
  56. package/src/context/editor.ts +408 -0
  57. package/src/context/epilogue.tsx +6 -0
  58. package/src/context/event.ts +36 -0
  59. package/src/context/exit.tsx +8 -0
  60. package/src/context/helper.tsx +26 -0
  61. package/src/context/kv.tsx +66 -0
  62. package/src/context/local.tsx +542 -0
  63. package/src/context/location.tsx +14 -0
  64. package/src/context/path-format.tsx +24 -0
  65. package/src/context/permission.tsx +26 -0
  66. package/src/context/project.tsx +115 -0
  67. package/src/context/prompt.tsx +18 -0
  68. package/src/context/route.tsx +60 -0
  69. package/src/context/runtime.tsx +62 -0
  70. package/src/context/sdk.tsx +151 -0
  71. package/src/context/sync.tsx +666 -0
  72. package/src/context/theme.tsx +332 -0
  73. package/src/context/thinking.ts +67 -0
  74. package/src/editor-zed.ts +286 -0
  75. package/src/editor.ts +101 -0
  76. package/src/feature-plugins/builtins.ts +36 -0
  77. package/src/feature-plugins/home/footer.tsx +100 -0
  78. package/src/feature-plugins/home/tips-view.tsx +287 -0
  79. package/src/feature-plugins/home/tips.tsx +59 -0
  80. package/src/feature-plugins/sidebar/context.tsx +65 -0
  81. package/src/feature-plugins/sidebar/files.tsx +70 -0
  82. package/src/feature-plugins/sidebar/footer.tsx +98 -0
  83. package/src/feature-plugins/sidebar/lsp.tsx +65 -0
  84. package/src/feature-plugins/sidebar/mcp.tsx +97 -0
  85. package/src/feature-plugins/sidebar/todo.tsx +49 -0
  86. package/src/feature-plugins/system/diff-viewer-file-tree-utils.ts +232 -0
  87. package/src/feature-plugins/system/diff-viewer-file-tree.tsx +162 -0
  88. package/src/feature-plugins/system/diff-viewer-ui.tsx +103 -0
  89. package/src/feature-plugins/system/diff-viewer.tsx +1077 -0
  90. package/src/feature-plugins/system/notifications.ts +94 -0
  91. package/src/feature-plugins/system/plugins.tsx +269 -0
  92. package/src/feature-plugins/system/which-key.tsx +608 -0
  93. package/src/index.tsx +1 -0
  94. package/src/keymap.tsx +290 -0
  95. package/src/logo.ts +11 -0
  96. package/src/parsers-config.ts +386 -0
  97. package/src/plugin/adapters.tsx +355 -0
  98. package/src/plugin/api.ts +52 -0
  99. package/src/plugin/command-shim.ts +109 -0
  100. package/src/plugin/runtime.tsx +81 -0
  101. package/src/plugin/slots.tsx +65 -0
  102. package/src/prompt/display.ts +48 -0
  103. package/src/prompt/frecency.tsx +80 -0
  104. package/src/prompt/history.tsx +111 -0
  105. package/src/prompt/part.ts +29 -0
  106. package/src/prompt/stash.tsx +89 -0
  107. package/src/prompt/traits.ts +29 -0
  108. package/src/routes/home/session-destination.tsx +41 -0
  109. package/src/routes/home.tsx +95 -0
  110. package/src/routes/session/dialog-fork-from-timeline.tsx +76 -0
  111. package/src/routes/session/dialog-message.tsx +109 -0
  112. package/src/routes/session/dialog-subagent.tsx +26 -0
  113. package/src/routes/session/dialog-timeline.tsx +47 -0
  114. package/src/routes/session/footer.tsx +91 -0
  115. package/src/routes/session/index.tsx +2710 -0
  116. package/src/routes/session/permission.tsx +718 -0
  117. package/src/routes/session/question.tsx +514 -0
  118. package/src/routes/session/sidebar.tsx +103 -0
  119. package/src/routes/session/subagent-footer.tsx +132 -0
  120. package/src/runtime.tsx +9 -0
  121. package/src/terminal-win32.ts +130 -0
  122. package/src/theme/assets/aura.json +69 -0
  123. package/src/theme/assets/ayu.json +80 -0
  124. package/src/theme/assets/carbonfox.json +248 -0
  125. package/src/theme/assets/catppuccin-frappe.json +230 -0
  126. package/src/theme/assets/catppuccin-macchiato.json +230 -0
  127. package/src/theme/assets/catppuccin.json +112 -0
  128. package/src/theme/assets/cobalt2.json +225 -0
  129. package/src/theme/assets/cursor.json +249 -0
  130. package/src/theme/assets/dracula.json +219 -0
  131. package/src/theme/assets/everforest.json +241 -0
  132. package/src/theme/assets/flexoki.json +237 -0
  133. package/src/theme/assets/github.json +233 -0
  134. package/src/theme/assets/gruvbox.json +242 -0
  135. package/src/theme/assets/kanagawa.json +77 -0
  136. package/src/theme/assets/lucent-orng.json +234 -0
  137. package/src/theme/assets/material.json +235 -0
  138. package/src/theme/assets/matrix.json +77 -0
  139. package/src/theme/assets/mercury.json +252 -0
  140. package/src/theme/assets/monokai.json +221 -0
  141. package/src/theme/assets/nightowl.json +221 -0
  142. package/src/theme/assets/nord.json +223 -0
  143. package/src/theme/assets/one-dark.json +84 -0
  144. package/src/theme/assets/opencode.json +245 -0
  145. package/src/theme/assets/orng.json +249 -0
  146. package/src/theme/assets/osaka-jade.json +93 -0
  147. package/src/theme/assets/palenight.json +222 -0
  148. package/src/theme/assets/rosepine.json +234 -0
  149. package/src/theme/assets/solarized.json +223 -0
  150. package/src/theme/assets/synthwave84.json +226 -0
  151. package/src/theme/assets/tokyonight.json +243 -0
  152. package/src/theme/assets/vercel.json +245 -0
  153. package/src/theme/assets/vesper.json +218 -0
  154. package/src/theme/assets/zenburn.json +223 -0
  155. package/src/theme/index.ts +1089 -0
  156. package/src/ui/border.ts +21 -0
  157. package/src/ui/dialog-alert.tsx +66 -0
  158. package/src/ui/dialog-confirm.tsx +108 -0
  159. package/src/ui/dialog-export-options.tsx +217 -0
  160. package/src/ui/dialog-help.tsx +40 -0
  161. package/src/ui/dialog-prompt.tsx +126 -0
  162. package/src/ui/dialog-select.tsx +790 -0
  163. package/src/ui/dialog.tsx +231 -0
  164. package/src/ui/link.tsx +34 -0
  165. package/src/ui/spinner.ts +368 -0
  166. package/src/ui/toast.tsx +102 -0
  167. package/src/util/collapse-tool-output.ts +19 -0
  168. package/src/util/error.ts +182 -0
  169. package/src/util/filetype.ts +130 -0
  170. package/src/util/format.ts +20 -0
  171. package/src/util/layout.ts +25 -0
  172. package/src/util/locale.ts +86 -0
  173. package/src/util/model.ts +28 -0
  174. package/src/util/path.ts +12 -0
  175. package/src/util/persistence.ts +33 -0
  176. package/src/util/presentation.ts +38 -0
  177. package/src/util/provider-origin.ts +7 -0
  178. package/src/util/record.ts +3 -0
  179. package/src/util/renderer.ts +7 -0
  180. package/src/util/revert-diff.ts +18 -0
  181. package/src/util/scroll.ts +27 -0
  182. package/src/util/selection.ts +79 -0
  183. package/src/util/session.ts +3 -0
  184. package/src/util/signal.ts +51 -0
  185. package/src/util/system.ts +20 -0
  186. package/src/util/tool-display.ts +13 -0
  187. package/src/util/transcript.ts +112 -0
  188. package/sst-env.d.ts +10 -0
  189. package/test/app-lifecycle.test.tsx +128 -0
  190. package/test/cli/cmd/tui/dialog-workspace-create.test.ts +28 -0
  191. package/test/cli/cmd/tui/model-options.test.ts +32 -0
  192. package/test/cli/cmd/tui/notifications.test.ts +267 -0
  193. package/test/cli/cmd/tui/provider-options.test.ts +41 -0
  194. package/test/cli/cmd/tui/sync-fixture.tsx +70 -0
  195. package/test/cli/cmd/tui/sync-live-hydration.test.tsx +262 -0
  196. package/test/cli/cmd/tui/sync-undefined-messages.test.tsx +43 -0
  197. package/test/cli/cmd/tui/sync.test.tsx +65 -0
  198. package/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +92 -0
  199. package/test/cli/tui/data.test.tsx +486 -0
  200. package/test/cli/tui/dialog-prompt.test.tsx +147 -0
  201. package/test/cli/tui/diff-viewer-file-tree.test.tsx +200 -0
  202. package/test/cli/tui/diff-viewer.test.tsx +268 -0
  203. package/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +351 -0
  204. package/test/cli/tui/prompt-submit-race.test.ts +98 -0
  205. package/test/cli/tui/thinking.test.ts +36 -0
  206. package/test/cli/tui/use-event.test.tsx +148 -0
  207. package/test/clipboard.test.ts +19 -0
  208. package/test/component/dialog-session-list.test.ts +46 -0
  209. package/test/config.test.tsx +127 -0
  210. package/test/context/local.test.ts +22 -0
  211. package/test/editor.test.ts +32 -0
  212. package/test/feature-plugins/diff-viewer-file-tree-utils.test.ts +323 -0
  213. package/test/fixture/fixture.ts +13 -0
  214. package/test/fixture/tui-environment.tsx +32 -0
  215. package/test/fixture/tui-plugin.ts +36 -0
  216. package/test/fixture/tui-runtime.ts +12 -0
  217. package/test/fixture/tui-sdk.ts +109 -0
  218. package/test/index.test.tsx +6 -0
  219. package/test/keymap.test.tsx +141 -0
  220. package/test/plugin/runtime.test.ts +50 -0
  221. package/test/plugin/slots.test.tsx +38 -0
  222. package/test/prompt/display.test.ts +33 -0
  223. package/test/prompt/history.test.ts +39 -0
  224. package/test/prompt/jsonl.test.ts +24 -0
  225. package/test/prompt/local-attachment.test.ts +43 -0
  226. package/test/prompt/part.test.ts +53 -0
  227. package/test/prompt/persistence.test.ts +23 -0
  228. package/test/prompt/traits.test.ts +25 -0
  229. package/test/runtime.test.tsx +37 -0
  230. package/test/theme.test.ts +81 -0
  231. package/test/util/error.test.ts +49 -0
  232. package/test/util/filetype.test.ts +16 -0
  233. package/test/util/format.test.ts +59 -0
  234. package/test/util/model.test.ts +9 -0
  235. package/test/util/presentation.test.ts +8 -0
  236. package/test/util/renderer.test.ts +30 -0
  237. package/test/util/revert-diff.test.ts +35 -0
  238. package/test/util/session.test.ts +10 -0
  239. package/test/util/tool-display.test.ts +40 -0
  240. package/test/util/transcript.test.ts +421 -0
  241. package/tsconfig.json +10 -0
@@ -0,0 +1,70 @@
1
+ import type { TuiPlugin, TuiPluginApi } from "@neurocode-ai/plugin/tui"
2
+ import type { BuiltinTuiPlugin } from "../builtins"
3
+ import { createMemo, For, Show, createSignal } from "solid-js"
4
+ import { Locale } from "../../util/locale"
5
+
6
+ const id = "internal:sidebar-files"
7
+
8
+ function changeCountWidth(item: { additions: number; deletions: number }) {
9
+ return [item.additions ? `+${item.additions}` : "", item.deletions ? `-${item.deletions}` : ""]
10
+ .filter(Boolean)
11
+ .join(" ").length
12
+ }
13
+
14
+ function View(props: { api: TuiPluginApi; session_id: string }) {
15
+ const [open, setOpen] = createSignal(true)
16
+ const theme = () => props.api.theme.current
17
+ const list = createMemo(() => props.api.state.session.diff(props.session_id))
18
+
19
+ return (
20
+ <Show when={list().length > 0}>
21
+ <box>
22
+ <box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
23
+ <Show when={list().length > 2}>
24
+ <text fg={theme().text}>{open() ? "▼" : "▶"}</text>
25
+ </Show>
26
+ <text fg={theme().text}>
27
+ <b>Modified Files</b>
28
+ </text>
29
+ </box>
30
+ <Show when={list().length <= 2 || open()}>
31
+ <For each={list()}>
32
+ {(item) => (
33
+ <box flexDirection="row" gap={1} justifyContent="space-between">
34
+ <text fg={theme().textMuted} wrapMode="none">
35
+ {Locale.truncateLeft(item.file, Math.max(2, 36 - changeCountWidth(item)))}
36
+ </text>
37
+ <box flexDirection="row" gap={1} flexShrink={0}>
38
+ <Show when={item.additions}>
39
+ <text fg={theme().diffAdded}>+{item.additions}</text>
40
+ </Show>
41
+ <Show when={item.deletions}>
42
+ <text fg={theme().diffRemoved}>-{item.deletions}</text>
43
+ </Show>
44
+ </box>
45
+ </box>
46
+ )}
47
+ </For>
48
+ </Show>
49
+ </box>
50
+ </Show>
51
+ )
52
+ }
53
+
54
+ const tui: TuiPlugin = async (api) => {
55
+ api.slots.register({
56
+ order: 500,
57
+ slots: {
58
+ sidebar_content(_ctx, props) {
59
+ return <View api={api} session_id={props.session_id} />
60
+ },
61
+ },
62
+ })
63
+ }
64
+
65
+ const plugin: BuiltinTuiPlugin = {
66
+ id,
67
+ tui,
68
+ }
69
+
70
+ export default plugin
@@ -0,0 +1,98 @@
1
+ import type { TuiPlugin, TuiPluginApi } from "@neurocode-ai/plugin/tui"
2
+ import type { BuiltinTuiPlugin } from "../builtins"
3
+ import { createMemo, Show } from "solid-js"
4
+ import { abbreviateHome } from "../../runtime"
5
+ import { useTuiPaths } from "../../context/runtime"
6
+
7
+ const id = "internal:sidebar-footer"
8
+
9
+ function View(props: { api: TuiPluginApi; sessionID: string }) {
10
+ const paths = useTuiPaths()
11
+ const theme = () => props.api.theme.current
12
+ const has = createMemo(() =>
13
+ props.api.state.provider.some(
14
+ (item) => item.id !== "opencode" && item.id !== "neuro" || Object.values(item.models).some((model) => model.cost?.input !== 0),
15
+ ),
16
+ )
17
+ const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
18
+ const show = createMemo(() => !has() && !done())
19
+ const path = createMemo(() => {
20
+ const session = props.api.state.session.get(props.sessionID)
21
+ const dir = session?.directory || props.api.state.path.directory || paths.cwd
22
+ const out = abbreviateHome(dir, paths.home)
23
+ const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
24
+ const text = branch ? out + ":" + branch : out
25
+ const list = text.split("/")
26
+ return {
27
+ parent: list.slice(0, -1).join("/"),
28
+ name: list.at(-1) ?? "",
29
+ }
30
+ })
31
+
32
+ return (
33
+ <box gap={1}>
34
+ <Show when={show()}>
35
+ <box
36
+ backgroundColor={theme().backgroundElement}
37
+ paddingTop={1}
38
+ paddingBottom={1}
39
+ paddingLeft={2}
40
+ paddingRight={2}
41
+ flexDirection="row"
42
+ gap={1}
43
+ >
44
+ <text flexShrink={0} fg={theme().text}>
45
+
46
+ </text>
47
+ <box flexGrow={1} gap={1}>
48
+ <box flexDirection="row" justifyContent="space-between">
49
+ <text fg={theme().text}>
50
+ <b>Getting started</b>
51
+ </text>
52
+ <text fg={theme().textMuted} onMouseDown={() => props.api.kv.set("dismissed_getting_started", true)}>
53
+
54
+ </text>
55
+ </box>
56
+ <text fg={theme().textMuted}>NeuroCode includes free models so you can start immediately.</text>
57
+ <text fg={theme().textMuted}>
58
+ Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
59
+ </text>
60
+ <box flexDirection="row" gap={1} justifyContent="space-between">
61
+ <text fg={theme().text}>Connect provider</text>
62
+ <text fg={theme().textMuted}>/connect</text>
63
+ </box>
64
+ </box>
65
+ </box>
66
+ </Show>
67
+ <text>
68
+ <span style={{ fg: theme().textMuted }}>{path().parent}/</span>
69
+ <span style={{ fg: theme().text }}>{path().name}</span>
70
+ </text>
71
+ <text fg={theme().textMuted}>
72
+ <span style={{ fg: theme().success }}>•</span> <b>Neuro</b>
73
+ <span style={{ fg: theme().text }}>
74
+ <b>Code</b>
75
+ </span>{" "}
76
+ <span>{props.api.app.version}</span>
77
+ </text>
78
+ </box>
79
+ )
80
+ }
81
+
82
+ const tui: TuiPlugin = async (api) => {
83
+ api.slots.register({
84
+ order: 100,
85
+ slots: {
86
+ sidebar_footer(_ctx, props) {
87
+ return <View api={api} sessionID={props.session_id} />
88
+ },
89
+ },
90
+ })
91
+ }
92
+
93
+ const plugin: BuiltinTuiPlugin = {
94
+ id,
95
+ tui,
96
+ }
97
+
98
+ export default plugin
@@ -0,0 +1,65 @@
1
+ import type { TuiPlugin, TuiPluginApi } from "@neurocode-ai/plugin/tui"
2
+ import type { BuiltinTuiPlugin } from "../builtins"
3
+ import { createMemo, For, Show, createSignal } from "solid-js"
4
+
5
+ const id = "internal:sidebar-lsp"
6
+
7
+ function View(props: { api: TuiPluginApi }) {
8
+ const [open, setOpen] = createSignal(true)
9
+ const theme = () => props.api.theme.current
10
+ const list = createMemo(() => props.api.state.lsp())
11
+ const off = createMemo(() => !props.api.state.config.lsp)
12
+
13
+ return (
14
+ <box>
15
+ <box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
16
+ <Show when={list().length > 2}>
17
+ <text fg={theme().text}>{open() ? "▼" : "▶"}</text>
18
+ </Show>
19
+ <text fg={theme().text}>
20
+ <b>LSP</b>
21
+ </text>
22
+ </box>
23
+ <Show when={list().length <= 2 || open()}>
24
+ <Show when={list().length === 0}>
25
+ <text fg={theme().textMuted}>{off() ? "LSPs are disabled" : "LSPs will activate as files are read"}</text>
26
+ </Show>
27
+ <For each={list()}>
28
+ {(item) => (
29
+ <box flexDirection="row" gap={1}>
30
+ <text
31
+ flexShrink={0}
32
+ style={{
33
+ fg: item.status === "connected" ? theme().success : theme().error,
34
+ }}
35
+ >
36
+
37
+ </text>
38
+ <text fg={theme().textMuted}>
39
+ {item.id} {item.root}
40
+ </text>
41
+ </box>
42
+ )}
43
+ </For>
44
+ </Show>
45
+ </box>
46
+ )
47
+ }
48
+
49
+ const tui: TuiPlugin = async (api) => {
50
+ api.slots.register({
51
+ order: 300,
52
+ slots: {
53
+ sidebar_content() {
54
+ return <View api={api} />
55
+ },
56
+ },
57
+ })
58
+ }
59
+
60
+ const plugin: BuiltinTuiPlugin = {
61
+ id,
62
+ tui,
63
+ }
64
+
65
+ export default plugin
@@ -0,0 +1,97 @@
1
+ import type { TuiPlugin, TuiPluginApi } from "@neurocode-ai/plugin/tui"
2
+ import type { BuiltinTuiPlugin } from "../builtins"
3
+ import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
4
+
5
+ const id = "internal:sidebar-mcp"
6
+
7
+ function View(props: { api: TuiPluginApi }) {
8
+ const [open, setOpen] = createSignal(true)
9
+ const theme = () => props.api.theme.current
10
+ const list = createMemo(() => props.api.state.mcp())
11
+ const on = createMemo(() => list().filter((item) => item.status === "connected").length)
12
+ const bad = createMemo(
13
+ () =>
14
+ list().filter(
15
+ (item) =>
16
+ item.status === "failed" || item.status === "needs_auth" || item.status === "needs_client_registration",
17
+ ).length,
18
+ )
19
+
20
+ const dot = (status: string) => {
21
+ if (status === "connected") return theme().success
22
+ if (status === "failed") return theme().error
23
+ if (status === "disabled") return theme().textMuted
24
+ if (status === "needs_auth") return theme().warning
25
+ if (status === "needs_client_registration") return theme().error
26
+ return theme().textMuted
27
+ }
28
+
29
+ return (
30
+ <Show when={list().length > 0}>
31
+ <box>
32
+ <box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
33
+ <Show when={list().length > 2}>
34
+ <text fg={theme().text}>{open() ? "▼" : "▶"}</text>
35
+ </Show>
36
+ <text fg={theme().text}>
37
+ <b>MCP</b>
38
+ <Show when={!open()}>
39
+ <span style={{ fg: theme().textMuted }}>
40
+ {" "}
41
+ ({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
42
+ </span>
43
+ </Show>
44
+ </text>
45
+ </box>
46
+ <Show when={list().length <= 2 || open()}>
47
+ <For each={list()}>
48
+ {(item) => (
49
+ <box flexDirection="row" gap={1}>
50
+ <text
51
+ flexShrink={0}
52
+ style={{
53
+ fg: dot(item.status),
54
+ }}
55
+ >
56
+
57
+ </text>
58
+ <text fg={theme().text} wrapMode="word">
59
+ {item.name}{" "}
60
+ <span style={{ fg: theme().textMuted }}>
61
+ <Switch fallback={item.status}>
62
+ <Match when={item.status === "connected"}>Connected</Match>
63
+ <Match when={item.status === "failed"}>
64
+ <i>{item.error}</i>
65
+ </Match>
66
+ <Match when={item.status === "disabled"}>Disabled</Match>
67
+ <Match when={item.status === "needs_auth"}>Needs auth</Match>
68
+ <Match when={item.status === "needs_client_registration"}>Needs client ID</Match>
69
+ </Switch>
70
+ </span>
71
+ </text>
72
+ </box>
73
+ )}
74
+ </For>
75
+ </Show>
76
+ </box>
77
+ </Show>
78
+ )
79
+ }
80
+
81
+ const tui: TuiPlugin = async (api) => {
82
+ api.slots.register({
83
+ order: 200,
84
+ slots: {
85
+ sidebar_content() {
86
+ return <View api={api} />
87
+ },
88
+ },
89
+ })
90
+ }
91
+
92
+ const plugin: BuiltinTuiPlugin = {
93
+ id,
94
+ tui,
95
+ }
96
+
97
+ export default plugin
@@ -0,0 +1,49 @@
1
+ import type { TuiPlugin, TuiPluginApi } from "@neurocode-ai/plugin/tui"
2
+ import type { BuiltinTuiPlugin } from "../builtins"
3
+ import { createMemo, For, Show, createSignal } from "solid-js"
4
+ import { TodoItem } from "../../component/todo-item"
5
+
6
+ const id = "internal:sidebar-todo"
7
+
8
+ function View(props: { api: TuiPluginApi; session_id: string }) {
9
+ const [open, setOpen] = createSignal(true)
10
+ const theme = () => props.api.theme.current
11
+ const list = createMemo(() => props.api.state.session.todo(props.session_id))
12
+ const show = createMemo(() => list().length > 0 && list().some((item) => item.status !== "completed"))
13
+
14
+ return (
15
+ <Show when={show()}>
16
+ <box>
17
+ <box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
18
+ <Show when={list().length > 2}>
19
+ <text fg={theme().text}>{open() ? "▼" : "▶"}</text>
20
+ </Show>
21
+ <text fg={theme().text}>
22
+ <b>Todo</b>
23
+ </text>
24
+ </box>
25
+ <Show when={list().length <= 2 || open()}>
26
+ <For each={list()}>{(item) => <TodoItem status={item.status} content={item.content} />}</For>
27
+ </Show>
28
+ </box>
29
+ </Show>
30
+ )
31
+ }
32
+
33
+ const tui: TuiPlugin = async (api) => {
34
+ api.slots.register({
35
+ order: 400,
36
+ slots: {
37
+ sidebar_content(_ctx, props) {
38
+ return <View api={api} session_id={props.session_id} />
39
+ },
40
+ },
41
+ })
42
+ }
43
+
44
+ const plugin: BuiltinTuiPlugin = {
45
+ id,
46
+ tui,
47
+ }
48
+
49
+ export default plugin
@@ -0,0 +1,232 @@
1
+ // Paths branch softly through the screen,
2
+ // A quiet tree of changed designs;
3
+ // Each leaf remembers what has been,
4
+ // And waits where careful light aligns.
5
+
6
+ export type FileTreeItem = {
7
+ readonly file: string
8
+ readonly status?: "added" | "deleted" | "modified"
9
+ }
10
+
11
+ export type FileTreeNode = {
12
+ readonly id: number
13
+ readonly name: string
14
+ readonly parent: number | undefined
15
+ readonly children: number[]
16
+ readonly depth: number
17
+ readonly kind: "directory" | "file"
18
+ readonly fileIndex?: number
19
+ }
20
+
21
+ export type FileTree = {
22
+ readonly roots: number[]
23
+ readonly nodes: FileTreeNode[]
24
+ }
25
+
26
+ export type FileTreeRow = {
27
+ readonly id: number
28
+ readonly depth: number
29
+ readonly kind: "directory" | "file"
30
+ readonly name: string
31
+ readonly fileIndex?: number
32
+ }
33
+
34
+ export function buildFileTree(files: readonly FileTreeItem[]): FileTree {
35
+ const roots: number[] = []
36
+ const nodes: FileTreeNode[] = []
37
+ const directoryByPath = new Map<string, number>()
38
+
39
+ files.forEach((file, fileIndex) => {
40
+ const segments = file.file.split("/").filter(Boolean)
41
+ if (segments.length === 0) return
42
+
43
+ const parent = segments.slice(0, -1).reduce(
44
+ (state, segment) => {
45
+ const directoryPath = state.path ? `${state.path}/${segment}` : segment
46
+ const existing = directoryByPath.get(directoryPath)
47
+ if (existing !== undefined) return { id: existing, path: directoryPath, depth: state.depth + 1 }
48
+
49
+ const id = addFileTreeNode(nodes, roots, {
50
+ name: segment,
51
+ parent: state.id,
52
+ depth: state.depth,
53
+ kind: "directory",
54
+ })
55
+ directoryByPath.set(directoryPath, id)
56
+ return { id, path: directoryPath, depth: state.depth + 1 }
57
+ },
58
+ { id: undefined as number | undefined, path: "", depth: 0 },
59
+ )
60
+
61
+ addFileTreeNode(nodes, roots, {
62
+ name: segments[segments.length - 1]!,
63
+ parent: parent.id,
64
+ depth: parent.depth,
65
+ kind: "file",
66
+ fileIndex,
67
+ })
68
+ })
69
+
70
+ const tree = { roots, nodes }
71
+ tree.roots.sort((left, right) => compareFileTreeNodes(tree, left, right))
72
+ tree.nodes.forEach((node) => node.children.sort((left, right) => compareFileTreeNodes(tree, left, right)))
73
+ return tree
74
+ }
75
+
76
+ export function flattenFileTree(tree: FileTree, expanded?: ReadonlySet<number>): FileTreeRow[] {
77
+ const rows: FileTreeRow[] = []
78
+ const visit = (id: number, depth: number) => {
79
+ const node = tree.nodes[id]!
80
+ if (node.kind === "file") {
81
+ rows.push({
82
+ id: node.id,
83
+ depth,
84
+ kind: node.kind,
85
+ name: node.name,
86
+ fileIndex: node.fileIndex,
87
+ })
88
+ return
89
+ }
90
+
91
+ const chain = collapsedFileTreeDirectoryChain(tree, node.id)
92
+ const last = chain[chain.length - 1]!
93
+ rows.push({
94
+ id: node.id,
95
+ depth,
96
+ kind: node.kind,
97
+ name: chain.map((item) => item.name).join("/"),
98
+ fileIndex: node.fileIndex,
99
+ })
100
+ if (!expanded || expanded.has(node.id)) last.children.forEach((child) => visit(child, depth + 1))
101
+ }
102
+ tree.roots.forEach((root) => visit(root, 0))
103
+ return rows
104
+ }
105
+
106
+ function collapsedFileTreeDirectoryChain(tree: FileTree, id: number): FileTreeNode[] {
107
+ const node = tree.nodes[id]!
108
+ const child = node.children.length === 1 ? tree.nodes[node.children[0]!] : undefined
109
+ if (child?.kind !== "directory") return [node]
110
+ return [node, ...collapsedFileTreeDirectoryChain(tree, child.id)]
111
+ }
112
+
113
+ export function compareFileTreeNodes(tree: FileTree, left: number, right: number) {
114
+ const leftNode = tree.nodes[left]!
115
+ const rightNode = tree.nodes[right]!
116
+ if (leftNode.kind !== rightNode.kind) return leftNode.kind === "directory" ? -1 : 1
117
+ if (leftNode.name < rightNode.name) return -1
118
+ if (leftNode.name > rightNode.name) return 1
119
+ return left - right
120
+ }
121
+
122
+ export function moveFileTreeSelection(rows: readonly FileTreeRow[], selected: number | undefined, offset: number) {
123
+ if (rows.length === 0) return undefined
124
+ const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
125
+ if (index === -1) return rows[0]!.id
126
+ return rows[Math.max(0, Math.min(rows.length - 1, index + offset))]!.id
127
+ }
128
+
129
+ export function moveFileTreeSelectionToFirstChild(rows: readonly FileTreeRow[], selected: number | undefined) {
130
+ const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
131
+ const row = index === -1 ? undefined : rows[index]
132
+ if (row?.kind !== "directory") return selected
133
+ const child = rows[index + 1]
134
+ return child && child.depth > row.depth ? child.id : selected
135
+ }
136
+
137
+ export function moveFileTreeSelectionToParent(rows: readonly FileTreeRow[], selected: number | undefined) {
138
+ const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
139
+ const row = index === -1 ? undefined : rows[index]
140
+ if (!row || row.depth === 0) return selected
141
+ return rows.findLast((item, itemIndex) => itemIndex < index && item.depth < row.depth)?.id ?? selected
142
+ }
143
+
144
+ export function moveFileTreeSelectionToFile(
145
+ rows: readonly FileTreeRow[],
146
+ selected: number | undefined,
147
+ offset: number,
148
+ ) {
149
+ const fileRows = rows.filter((row) => row.fileIndex !== undefined)
150
+ if (fileRows.length === 0) return undefined
151
+ const selectedIndex = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
152
+ if (selectedIndex === -1) return offset < 0 ? fileRows[fileRows.length - 1]!.id : fileRows[0]!.id
153
+ const next =
154
+ offset < 0
155
+ ? fileRows.findLast((row) => rows.findIndex((item) => item.id === row.id) < selectedIndex)
156
+ : fileRows.find((row) => rows.findIndex((item) => item.id === row.id) > selectedIndex)
157
+ return next?.id ?? (offset < 0 ? fileRows[0]!.id : fileRows[fileRows.length - 1]!.id)
158
+ }
159
+
160
+ export function fileTreeFileSelection(tree: FileTree, fileIndex: number) {
161
+ const node = tree.nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
162
+ if (!node) return undefined
163
+ return {
164
+ highlightedNode: node.id,
165
+ expandedNodes: fileTreeParentDirectories(tree, node.id),
166
+ }
167
+ }
168
+
169
+ export function singlePatchFileIndex(
170
+ selected: number | undefined,
171
+ active: number | undefined,
172
+ current: number | undefined,
173
+ first: number | undefined,
174
+ ) {
175
+ return selected ?? active ?? current ?? first
176
+ }
177
+
178
+ export function orderedPatchFileIndexes(rows: readonly FileTreeRow[]) {
179
+ return rows.flatMap((row) => (row.fileIndex === undefined ? [] : [row.fileIndex]))
180
+ }
181
+
182
+ export function showDiffViewerFileTree(showFileTree: boolean, fileCount: number) {
183
+ return showFileTree && fileCount > 0
184
+ }
185
+
186
+ export function movePatchFileIndex(fileIndexes: readonly number[], current: number | undefined, offset: number) {
187
+ if (fileIndexes.length === 0) return undefined
188
+ const index = current === undefined ? -1 : fileIndexes.indexOf(current)
189
+ if (index === -1) return fileIndexes[0]
190
+ return fileIndexes[Math.max(0, Math.min(fileIndexes.length - 1, index + offset))]
191
+ }
192
+
193
+ export function allExpandedFileTreeDirectories(tree: FileTree) {
194
+ return new Set(tree.nodes.filter((node) => node.kind === "directory").map((node) => node.id))
195
+ }
196
+
197
+ export function toggleFileTreeDirectory(tree: FileTree, expanded: ReadonlySet<number>, selected: number | undefined) {
198
+ if (selected === undefined || tree.nodes[selected]?.kind !== "directory") return expanded
199
+ const next = new Set(expanded)
200
+ if (next.has(selected)) next.delete(selected)
201
+ else next.add(selected)
202
+ return next
203
+ }
204
+
205
+ export function setFileTreeDirectoryExpanded(
206
+ tree: FileTree,
207
+ expanded: ReadonlySet<number>,
208
+ selected: number | undefined,
209
+ value: boolean,
210
+ ) {
211
+ if (selected === undefined || tree.nodes[selected]?.kind !== "directory") return expanded
212
+ const next = new Set(expanded)
213
+ if (value) next.add(selected)
214
+ else next.delete(selected)
215
+ return next
216
+ }
217
+
218
+ function addFileTreeNode(nodes: FileTreeNode[], roots: number[], input: Omit<FileTreeNode, "id" | "children">) {
219
+ const id = nodes.length
220
+ nodes.push({ ...input, id, children: [] })
221
+ if (input.parent === undefined) roots.push(id)
222
+ else nodes[input.parent]!.children.push(id)
223
+ return id
224
+ }
225
+
226
+ function fileTreeParentDirectories(tree: FileTree, id: number) {
227
+ const result = new Set<number>()
228
+ for (let parent = tree.nodes[id]?.parent; parent !== undefined; parent = tree.nodes[parent]?.parent) {
229
+ result.add(parent)
230
+ }
231
+ return result
232
+ }