@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,80 @@
1
+ import path from "path"
2
+ import { onMount } from "solid-js"
3
+ import { createStore } from "solid-js/store"
4
+ import { createSimpleContext } from "../context/helper"
5
+ import { useTuiPaths } from "../context/runtime"
6
+ import { appendText, readText, writeText } from "../util/persistence"
7
+
8
+ type FrecencyEntry = { path: string; frequency: number; lastOpen: number }
9
+
10
+ export const MAX_FRECENCY_ENTRIES = 1000
11
+
12
+ export function parseFrecency(text: string) {
13
+ const latest = text
14
+ .split("\n")
15
+ .filter(Boolean)
16
+ .map((line) => {
17
+ try {
18
+ return JSON.parse(line) as FrecencyEntry
19
+ } catch {
20
+ return undefined
21
+ }
22
+ })
23
+ .filter((line): line is FrecencyEntry => line !== undefined)
24
+ .reduce<Record<string, FrecencyEntry>>((result, entry) => {
25
+ result[entry.path] = entry
26
+ return result
27
+ }, {})
28
+ return Object.values(latest)
29
+ .sort((a, b) => b.lastOpen - a.lastOpen)
30
+ .slice(0, MAX_FRECENCY_ENTRIES)
31
+ }
32
+
33
+ function calculateFrecency(entry?: { frequency: number; lastOpen: number }) {
34
+ if (!entry) return 0
35
+ return entry.frequency / (1 + (Date.now() - entry.lastOpen) / 86400000)
36
+ }
37
+
38
+ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleContext({
39
+ name: "Frecency",
40
+ init: () => {
41
+ const paths = useTuiPaths()
42
+ const frecencyPath = path.join(paths.state, "frecency.jsonl")
43
+ onMount(async () => {
44
+ const lines = parseFrecency(await readText(frecencyPath).catch(() => ""))
45
+ setStore(
46
+ "data",
47
+ Object.fromEntries(
48
+ lines.map((entry) => [entry.path, { frequency: entry.frequency, lastOpen: entry.lastOpen }]),
49
+ ),
50
+ )
51
+ if (lines.length > 0)
52
+ writeText(frecencyPath, lines.map((entry) => JSON.stringify(entry)).join("\n") + "\n").catch(() => {})
53
+ })
54
+
55
+ const [store, setStore] = createStore({ data: {} as Record<string, { frequency: number; lastOpen: number }> })
56
+
57
+ function updateFrecency(filePath: string) {
58
+ const absolutePath = path.resolve(paths.cwd, filePath)
59
+ const newEntry = { frequency: (store.data[absolutePath]?.frequency || 0) + 1, lastOpen: Date.now() }
60
+ setStore("data", absolutePath, newEntry)
61
+ appendText(frecencyPath, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {})
62
+
63
+ if (Object.keys(store.data).length <= MAX_FRECENCY_ENTRIES) return
64
+ const sorted = Object.entries(store.data)
65
+ .sort(([, a], [, b]) => b.lastOpen - a.lastOpen)
66
+ .slice(0, MAX_FRECENCY_ENTRIES)
67
+ setStore("data", Object.fromEntries(sorted))
68
+ writeText(
69
+ frecencyPath,
70
+ sorted.map(([entryPath, entry]) => JSON.stringify({ path: entryPath, ...entry })).join("\n") + "\n",
71
+ ).catch(() => {})
72
+ }
73
+
74
+ return {
75
+ getFrecency: (filePath: string) => calculateFrecency(store.data[path.resolve(paths.cwd, filePath)]),
76
+ updateFrecency,
77
+ data: () => store.data,
78
+ }
79
+ },
80
+ })
@@ -0,0 +1,111 @@
1
+ import path from "path"
2
+ import { onMount } from "solid-js"
3
+ import { createStore, produce, unwrap } from "solid-js/store"
4
+ import type { AgentPart, FilePart, TextPart } from "@neurocode-ai/sdk/v2"
5
+ import { createSimpleContext } from "../context/helper"
6
+ import { useTuiPaths } from "../context/runtime"
7
+ import { appendText, readText, writeText } from "../util/persistence"
8
+
9
+ export type PromptInfo = {
10
+ input: string
11
+ mode?: "normal" | "shell"
12
+ parts: (
13
+ | Omit<FilePart, "id" | "messageID" | "sessionID">
14
+ | Omit<AgentPart, "id" | "messageID" | "sessionID">
15
+ | (Omit<TextPart, "id" | "messageID" | "sessionID"> & {
16
+ source?: {
17
+ text: {
18
+ start: number
19
+ end: number
20
+ value: string
21
+ }
22
+ }
23
+ })
24
+ )[]
25
+ }
26
+
27
+ export const MAX_HISTORY_ENTRIES = 50
28
+
29
+ export function parsePromptHistory(text: string) {
30
+ return text
31
+ .split("\n")
32
+ .filter(Boolean)
33
+ .map((line) => {
34
+ try {
35
+ return JSON.parse(line) as PromptInfo
36
+ } catch {
37
+ return undefined
38
+ }
39
+ })
40
+ .filter((line): line is PromptInfo => line !== undefined)
41
+ .slice(-MAX_HISTORY_ENTRIES)
42
+ }
43
+
44
+ export function isDuplicateEntry(previous: PromptInfo | undefined, next: PromptInfo): boolean {
45
+ if (!previous) return false
46
+ return JSON.stringify(previous) === JSON.stringify(next)
47
+ }
48
+
49
+ export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
50
+ name: "PromptHistory",
51
+ init: () => {
52
+ const paths = useTuiPaths()
53
+ const historyPath = path.join(paths.state, "prompt-history.jsonl")
54
+ onMount(async () => {
55
+ const lines = parsePromptHistory(await readText(historyPath).catch(() => ""))
56
+ setStore("history", lines)
57
+
58
+ // Rewrite valid retained entries to self-heal corruption and enforce the limit.
59
+ if (lines.length > 0)
60
+ writeText(historyPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
61
+ })
62
+
63
+ const [store, setStore] = createStore({
64
+ index: 0,
65
+ history: [] as PromptInfo[],
66
+ })
67
+
68
+ return {
69
+ move(direction: 1 | -1, input: string) {
70
+ if (!store.history.length) return undefined
71
+ const current = store.history.at(store.index)
72
+ if (!current) return undefined
73
+ if (current.input !== input && input.length) return
74
+ setStore(
75
+ produce((draft) => {
76
+ const next = store.index + direction
77
+ if (Math.abs(next) > store.history.length) return
78
+ if (next > 0) return
79
+ draft.index = next
80
+ }),
81
+ )
82
+ if (store.index === 0) return { input: "", parts: [] }
83
+ return store.history.at(store.index)
84
+ },
85
+ append(item: PromptInfo) {
86
+ const entry = structuredClone(unwrap(item))
87
+ if (isDuplicateEntry(store.history.at(-1), entry)) {
88
+ setStore("index", 0)
89
+ return
90
+ }
91
+ let trimmed = false
92
+ setStore(
93
+ produce((draft) => {
94
+ draft.history.push(entry)
95
+ if (draft.history.length > MAX_HISTORY_ENTRIES) {
96
+ draft.history = draft.history.slice(-MAX_HISTORY_ENTRIES)
97
+ trimmed = true
98
+ }
99
+ draft.index = 0
100
+ }),
101
+ )
102
+
103
+ if (trimmed) {
104
+ writeText(historyPath, store.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
105
+ return
106
+ }
107
+ appendText(historyPath, JSON.stringify(entry) + "\n").catch(() => {})
108
+ },
109
+ }
110
+ },
111
+ })
@@ -0,0 +1,29 @@
1
+ import { displaySlice } from "./display"
2
+
3
+ export function stripPromptPartIDs<Part extends { id: string; messageID: string; sessionID: string }>(part: Part) {
4
+ const { id: _id, messageID: _messageID, sessionID: _sessionID, ...rest } = part
5
+ return rest
6
+ }
7
+
8
+ export function expandPastedTextPlaceholders(text: string, parts: readonly unknown[]) {
9
+ return parts.reduce<string>((result, part) => {
10
+ if (!isPastedTextPart(part)) return result
11
+ return result.replace(part.source.text.value, part.text)
12
+ }, text)
13
+ }
14
+
15
+ function isPastedTextPart(part: unknown): part is { type: "text"; text: string; source: { text: { value: string } } } {
16
+ if (!part || typeof part !== "object" || !("type" in part) || part.type !== "text") return false
17
+ if (!("text" in part) || typeof part.text !== "string" || !("source" in part)) return false
18
+ const source = part.source
19
+ if (!source || typeof source !== "object" || !("text" in source)) return false
20
+ const text = source.text
21
+ return Boolean(text && typeof text === "object" && "value" in text && typeof text.value === "string")
22
+ }
23
+
24
+ export function expandTrackedPastedText(text: string, ranges: { start: number; end: number; text: string }[]) {
25
+ return ranges
26
+ .slice()
27
+ .sort((a, b) => b.start - a.start)
28
+ .reduce((result, part) => displaySlice(result, 0, part.start) + part.text + displaySlice(result, part.end), text)
29
+ }
@@ -0,0 +1,89 @@
1
+ import path from "path"
2
+ import { onMount } from "solid-js"
3
+ import { createStore, produce, unwrap } from "solid-js/store"
4
+ import { createSimpleContext } from "../context/helper"
5
+ import { useTuiPaths } from "../context/runtime"
6
+ import { appendText, readText, writeText } from "../util/persistence"
7
+ import type { PromptInfo } from "./history"
8
+
9
+ export type StashEntry = {
10
+ input: string
11
+ parts: PromptInfo["parts"]
12
+ timestamp: number
13
+ }
14
+
15
+ export const MAX_STASH_ENTRIES = 50
16
+
17
+ export function parsePromptStash(text: string) {
18
+ return text
19
+ .split("\n")
20
+ .filter(Boolean)
21
+ .map((line) => {
22
+ try {
23
+ return JSON.parse(line) as StashEntry
24
+ } catch {
25
+ return undefined
26
+ }
27
+ })
28
+ .filter((line): line is StashEntry => line !== undefined)
29
+ .slice(-MAX_STASH_ENTRIES)
30
+ }
31
+
32
+ export const { use: usePromptStash, provider: PromptStashProvider } = createSimpleContext({
33
+ name: "PromptStash",
34
+ init: () => {
35
+ const paths = useTuiPaths()
36
+ const stashPath = path.join(paths.state, "prompt-stash.jsonl")
37
+ onMount(async () => {
38
+ const lines = parsePromptStash(await readText(stashPath).catch(() => ""))
39
+ setStore("entries", lines)
40
+ if (lines.length > 0)
41
+ writeText(stashPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
42
+ })
43
+
44
+ const [store, setStore] = createStore({ entries: [] as StashEntry[] })
45
+
46
+ return {
47
+ list() {
48
+ return store.entries
49
+ },
50
+ push(entry: Omit<StashEntry, "timestamp">) {
51
+ const stash = structuredClone(unwrap({ ...entry, timestamp: Date.now() }))
52
+ let trimmed = false
53
+ setStore(
54
+ produce((draft) => {
55
+ draft.entries.push(stash)
56
+ if (draft.entries.length > MAX_STASH_ENTRIES) {
57
+ draft.entries = draft.entries.slice(-MAX_STASH_ENTRIES)
58
+ trimmed = true
59
+ }
60
+ }),
61
+ )
62
+
63
+ if (trimmed) {
64
+ writeText(stashPath, store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
65
+ return
66
+ }
67
+ appendText(stashPath, JSON.stringify(stash) + "\n").catch(() => {})
68
+ },
69
+ pop() {
70
+ if (store.entries.length === 0) return undefined
71
+ const entry = store.entries[store.entries.length - 1]
72
+ setStore(produce((draft) => void draft.entries.pop()))
73
+ writeText(
74
+ stashPath,
75
+ store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : "",
76
+ ).catch(() => {})
77
+ return entry
78
+ },
79
+ remove(index: number) {
80
+ if (index < 0 || index >= store.entries.length) return
81
+ setStore(produce((draft) => void draft.entries.splice(index, 1)))
82
+ writeText(
83
+ stashPath,
84
+ store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : "",
85
+ ).catch(() => {})
86
+ },
87
+ }
88
+ },
89
+ })
@@ -0,0 +1,29 @@
1
+ import type { EditorTraits } from "@opentui/core"
2
+
3
+ export type PromptMode = "normal" | "shell"
4
+
5
+ export interface PromptTraitsInput {
6
+ mode: PromptMode
7
+ autocompleteVisible: boolean
8
+ }
9
+
10
+ export type PromptTraits = EditorTraits & {
11
+ owner: "neurocode"
12
+ role: "prompt"
13
+ }
14
+
15
+ /** The managed textarea keymap owns `suspend`; these traits only describe capture and status. */
16
+ export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
17
+ const capture =
18
+ input.mode === "normal"
19
+ ? input.autocompleteVisible
20
+ ? (["escape", "navigate", "submit", "tab"] as const)
21
+ : (["tab"] as const)
22
+ : undefined
23
+ return {
24
+ capture,
25
+ status: input.mode === "shell" ? "SHELL" : undefined,
26
+ owner: "neurocode",
27
+ role: "prompt",
28
+ }
29
+ }
@@ -0,0 +1,41 @@
1
+ import {
2
+ createContext,
3
+ createMemo,
4
+ createSignal,
5
+ useContext,
6
+ type Accessor,
7
+ type ParentProps,
8
+ type Setter,
9
+ } from "solid-js"
10
+ import { useSync } from "../../context/sync"
11
+ import { useTuiPaths } from "../../context/runtime"
12
+
13
+ export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
14
+
15
+ type Context = {
16
+ destination: Accessor<HomeSessionDestination | undefined>
17
+ setDestination: Setter<HomeSessionDestination | undefined>
18
+ clear: () => void
19
+ }
20
+
21
+ const HomeSessionDestinationContext = createContext<Context>()
22
+
23
+ export function HomeSessionDestinationProvider(props: ParentProps) {
24
+ const sync = useSync()
25
+ const paths = useTuiPaths()
26
+ const [selected, setDestination] = createSignal<HomeSessionDestination>()
27
+ const destination = createMemo<HomeSessionDestination>(
28
+ () => selected() ?? { type: "directory", directory: sync.path.directory || paths.cwd, subdirectory: false },
29
+ )
30
+ return (
31
+ <HomeSessionDestinationContext.Provider
32
+ value={{ destination, setDestination, clear: () => setDestination(undefined) }}
33
+ >
34
+ {props.children}
35
+ </HomeSessionDestinationContext.Provider>
36
+ )
37
+ }
38
+
39
+ export function useHomeSessionDestination() {
40
+ return useContext(HomeSessionDestinationContext)
41
+ }
@@ -0,0 +1,95 @@
1
+ import { Prompt, type PromptRef } from "../component/prompt"
2
+ import { createEffect, createMemo, createSignal, onMount } from "solid-js"
3
+ import { Logo } from "../component/logo"
4
+ import { useSync } from "../context/sync"
5
+ import { Toast } from "../ui/toast"
6
+ import { useArgs } from "../context/args"
7
+ import { useRouteData } from "../context/route"
8
+ import { usePromptRef } from "../context/prompt"
9
+ import { useLocal } from "../context/local"
10
+ import { usePluginRuntime } from "../plugin/runtime"
11
+ import { useEditorContext } from "../context/editor"
12
+ import { useTerminalDimensions } from "@opentui/solid"
13
+ import { useTuiConfig } from "../config"
14
+ import { HomeSessionDestinationProvider } from "./home/session-destination"
15
+
16
+ let once = false
17
+ const placeholder = {
18
+ normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"],
19
+ shell: ["ls -la", "git status", "pwd"],
20
+ }
21
+
22
+ export function Home() {
23
+ const pluginRuntime = usePluginRuntime()
24
+ const sync = useSync()
25
+ const route = useRouteData("home")
26
+ const promptRef = usePromptRef()
27
+ const [ref, setRef] = createSignal<PromptRef | undefined>()
28
+ const args = useArgs()
29
+ const local = useLocal()
30
+ const editor = useEditorContext()
31
+ const dimensions = useTerminalDimensions()
32
+ const tuiConfig = useTuiConfig()
33
+ const promptMaxWidth = createMemo(() => {
34
+ const configured = tuiConfig.prompt?.max_width
35
+ if (configured === "auto") return Math.max(75, Math.floor(dimensions().width * 0.7))
36
+ return configured ?? 75
37
+ })
38
+ let sent = false
39
+
40
+ onMount(() => {
41
+ editor.clearSelection()
42
+ })
43
+
44
+ const bind = (r: PromptRef | undefined) => {
45
+ setRef(r)
46
+ promptRef.set(r)
47
+ if (once || !r) return
48
+ if (route.prompt) {
49
+ r.set(route.prompt)
50
+ once = true
51
+ return
52
+ }
53
+ if (!args.prompt) return
54
+ r.set({ input: args.prompt, parts: [] })
55
+ once = true
56
+ }
57
+
58
+ // Wait for sync and model store to be ready before auto-submitting --prompt
59
+ createEffect(() => {
60
+ const r = ref()
61
+ if (sent) return
62
+ if (!r) return
63
+ if (!sync.ready || !local.model.ready) return
64
+ if (!args.prompt) return
65
+ if (r.current.input !== args.prompt) return
66
+ sent = true
67
+ r.submit()
68
+ })
69
+
70
+ return (
71
+ <HomeSessionDestinationProvider>
72
+ <box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
73
+ <box flexGrow={1} minHeight={0} />
74
+ <box height={4} minHeight={0} flexShrink={1} />
75
+ <box flexShrink={0}>
76
+ <pluginRuntime.Slot name="home_logo" mode="replace">
77
+ <Logo />
78
+ </pluginRuntime.Slot>
79
+ </box>
80
+ <box height={1} minHeight={0} flexShrink={1} />
81
+ <box width="100%" maxWidth={promptMaxWidth()} zIndex={1000} paddingTop={1} flexShrink={0}>
82
+ <pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
83
+ <Prompt ref={bind} right={<pluginRuntime.Slot name="home_prompt_right" />} placeholders={placeholder} />
84
+ </pluginRuntime.Slot>
85
+ </box>
86
+ <pluginRuntime.Slot name="home_bottom" />
87
+ <box flexGrow={1} minHeight={0} />
88
+ <Toast />
89
+ </box>
90
+ <box width="100%" flexShrink={0}>
91
+ <pluginRuntime.Slot name="home_footer" mode="single_winner" />
92
+ </box>
93
+ </HomeSessionDestinationProvider>
94
+ )
95
+ }
@@ -0,0 +1,76 @@
1
+ import { createMemo, onMount } from "solid-js"
2
+ import { useSync } from "../../context/sync"
3
+ import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
4
+ import type { TextPart } from "@neurocode-ai/sdk/v2"
5
+ import { Locale } from "../../util/locale"
6
+ import { useSDK } from "../../context/sdk"
7
+ import { useRoute } from "../../context/route"
8
+ import { useDialog, type DialogContext } from "../../ui/dialog"
9
+ import type { PromptInfo } from "../../component/prompt/history"
10
+ import { stripPromptPartIDs as strip } from "../../prompt/part"
11
+
12
+ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID?: string) => void }) {
13
+ const sync = useSync()
14
+ const dialog = useDialog()
15
+ const sdk = useSDK()
16
+ const route = useRoute()
17
+
18
+ onMount(() => {
19
+ dialog.setSize("large")
20
+ })
21
+
22
+ const options = createMemo((): DialogSelectOption<string | undefined>[] => {
23
+ const messages = sync.data.message[props.sessionID] ?? []
24
+ const fullSession = {
25
+ title: "Full session",
26
+ value: undefined,
27
+ onSelect: async (dialog: DialogContext) => {
28
+ const forked = await sdk.client.session.fork({ sessionID: props.sessionID })
29
+ route.navigate({
30
+ sessionID: forked.data!.id,
31
+ type: "session",
32
+ })
33
+ dialog.clear()
34
+ },
35
+ } satisfies DialogSelectOption<string | undefined>
36
+ const result = [] as DialogSelectOption<string | undefined>[]
37
+ for (const message of messages) {
38
+ if (message.role !== "user") continue
39
+ const part = (sync.data.part[message.id] ?? []).find(
40
+ (x) => x.type === "text" && !x.synthetic && !x.ignored,
41
+ ) as TextPart
42
+ if (!part) continue
43
+ result.push({
44
+ title: part.text.replace(/\n/g, " "),
45
+ value: message.id,
46
+ footer: Locale.time(message.time.created),
47
+ onSelect: async (dialog) => {
48
+ const forked = await sdk.client.session.fork({
49
+ sessionID: props.sessionID,
50
+ messageID: message.id,
51
+ })
52
+ const parts = sync.data.part[message.id] ?? []
53
+ const prompt = parts.reduce(
54
+ (agg, part) => {
55
+ if (part.type === "text") {
56
+ if (!part.synthetic) agg.input += part.text
57
+ }
58
+ if (part.type === "file") agg.parts.push(strip(part))
59
+ return agg
60
+ },
61
+ { input: "", parts: [] as PromptInfo["parts"] },
62
+ )
63
+ route.navigate({
64
+ sessionID: forked.data!.id,
65
+ type: "session",
66
+ prompt,
67
+ })
68
+ dialog.clear()
69
+ },
70
+ })
71
+ }
72
+ return [fullSession, ...result.reverse()]
73
+ })
74
+
75
+ return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork session" options={options()} />
76
+ }
@@ -0,0 +1,109 @@
1
+ import { createMemo } from "solid-js"
2
+ import { useSync } from "../../context/sync"
3
+ import { DialogSelect } from "../../ui/dialog-select"
4
+ import { useSDK } from "../../context/sdk"
5
+ import { useRoute } from "../../context/route"
6
+ import { useClipboard } from "../../context/clipboard"
7
+ import type { PromptInfo } from "../../component/prompt/history"
8
+ import { stripPromptPartIDs as strip } from "../../prompt/part"
9
+
10
+ export function DialogMessage(props: {
11
+ messageID: string
12
+ sessionID: string
13
+ setPrompt?: (prompt: PromptInfo) => void
14
+ }) {
15
+ const sync = useSync()
16
+ const sdk = useSDK()
17
+ const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
18
+ const route = useRoute()
19
+ const clipboard = useClipboard()
20
+
21
+ return (
22
+ <DialogSelect
23
+ title="Message Actions"
24
+ options={[
25
+ {
26
+ title: "Revert",
27
+ value: "session.revert",
28
+ description: "undo messages and file changes",
29
+ onSelect: (dialog) => {
30
+ const msg = message()
31
+ if (!msg) return
32
+
33
+ void sdk.client.session.revert({
34
+ sessionID: props.sessionID,
35
+ messageID: msg.id,
36
+ })
37
+
38
+ if (props.setPrompt) {
39
+ const parts = sync.data.part[msg.id]
40
+ const promptInfo = parts.reduce(
41
+ (agg, part) => {
42
+ if (part.type === "text") {
43
+ if (!part.synthetic) agg.input += part.text
44
+ }
45
+ if (part.type === "file") agg.parts.push(strip(part))
46
+ return agg
47
+ },
48
+ { input: "", parts: [] as PromptInfo["parts"] },
49
+ )
50
+ props.setPrompt(promptInfo)
51
+ }
52
+
53
+ dialog.clear()
54
+ },
55
+ },
56
+ {
57
+ title: "Copy",
58
+ value: "message.copy",
59
+ description: "message text to clipboard",
60
+ onSelect: async (dialog) => {
61
+ const msg = message()
62
+ if (!msg) return
63
+
64
+ const parts = sync.data.part[msg.id]
65
+ const text = parts.reduce((agg, part) => {
66
+ if (part.type === "text" && !part.synthetic) {
67
+ agg += part.text
68
+ }
69
+ return agg
70
+ }, "")
71
+
72
+ await clipboard.write?.(text)
73
+ dialog.clear()
74
+ },
75
+ },
76
+ {
77
+ title: "Fork",
78
+ value: "session.fork",
79
+ description: "create a new session",
80
+ onSelect: async (dialog) => {
81
+ const result = await sdk.client.session.fork({
82
+ sessionID: props.sessionID,
83
+ messageID: props.messageID,
84
+ })
85
+ const msg = message()
86
+ const prompt = msg
87
+ ? sync.data.part[msg.id].reduce(
88
+ (agg, part) => {
89
+ if (part.type === "text") {
90
+ if (!part.synthetic) agg.input += part.text
91
+ }
92
+ if (part.type === "file") agg.parts.push(part)
93
+ return agg
94
+ },
95
+ { input: "", parts: [] as PromptInfo["parts"] },
96
+ )
97
+ : undefined
98
+ route.navigate({
99
+ sessionID: result.data!.id,
100
+ type: "session",
101
+ prompt,
102
+ })
103
+ dialog.clear()
104
+ },
105
+ },
106
+ ]}
107
+ />
108
+ )
109
+ }
@@ -0,0 +1,26 @@
1
+ import { DialogSelect } from "../../ui/dialog-select"
2
+ import { useRoute } from "../../context/route"
3
+
4
+ export function DialogSubagent(props: { sessionID: string }) {
5
+ const route = useRoute()
6
+
7
+ return (
8
+ <DialogSelect
9
+ title="Subagent Actions"
10
+ options={[
11
+ {
12
+ title: "Open",
13
+ value: "subagent.view",
14
+ description: "the subagent's session",
15
+ onSelect: (dialog) => {
16
+ route.navigate({
17
+ type: "session",
18
+ sessionID: props.sessionID,
19
+ })
20
+ dialog.clear()
21
+ },
22
+ },
23
+ ]}
24
+ />
25
+ )
26
+ }