@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,1713 @@
1
+ import {
2
+ BoxRenderable,
3
+ RGBA,
4
+ TextareaRenderable,
5
+ MouseEvent,
6
+ PasteEvent,
7
+ decodePasteBytes,
8
+ type KeyEvent,
9
+ type Renderable,
10
+ } from "@opentui/core"
11
+ import type { CommandContext } from "@opentui/keymap"
12
+ import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
13
+ import { registerNeurocodeSpinner } from "../register-spinner"
14
+ import path from "path"
15
+ import { fileURLToPath } from "url"
16
+ import { useLocal } from "../../context/local"
17
+ import { Flag } from "@neurocode-ai/core/flag/flag"
18
+ import { tint, useTheme } from "../../context/theme"
19
+ import { EmptyBorder, SplitBorder } from "../../ui/border"
20
+ import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
21
+ import { useClipboard } from "../../context/clipboard"
22
+ import { Spinner } from "../spinner"
23
+ import { useSDK } from "../../context/sdk"
24
+ import { useRoute } from "../../context/route"
25
+ import { useProject } from "../../context/project"
26
+ import { useSync } from "../../context/sync"
27
+ import { useEvent } from "../../context/event"
28
+ import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor"
29
+ import { normalizePromptContent, openEditor } from "../../editor"
30
+ import { useExit } from "../../context/exit"
31
+ import { promptOffsetWidth } from "../../prompt/display"
32
+ import { createStore, produce, unwrap } from "solid-js/store"
33
+ import { usePromptHistory, type PromptInfo } from "../../prompt/history"
34
+ import { computePromptTraits } from "../../prompt/traits"
35
+ import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
36
+ import { usePromptStash } from "../../prompt/stash"
37
+ import { DialogStash } from "../dialog-stash"
38
+ import { type AutocompleteRef, Autocomplete } from "./autocomplete"
39
+ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
40
+ import type { AssistantMessage, FilePart, UserMessage } from "@neurocode-ai/sdk/v2"
41
+ import { Locale } from "../../util/locale"
42
+ import { errorMessage } from "../../util/error"
43
+ import { formatDuration } from "../../util/format"
44
+ import { createColors, createFrames } from "../../ui/spinner"
45
+ import { useDialog } from "../../ui/dialog"
46
+ import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
47
+ import { DialogAlert } from "../../ui/dialog-alert"
48
+ import { useToast } from "../../ui/toast"
49
+ import { useKV } from "../../context/kv"
50
+ import { createFadeIn } from "../../util/signal"
51
+ import { DialogSkill } from "../dialog-skill"
52
+ import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
53
+ import { useArgs } from "../../context/args"
54
+ import { NEUROCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useNeurocodeKeymap } from "../../keymap"
55
+ import { useTuiConfig } from "../../config"
56
+ import { usePromptWorkspace } from "./workspace"
57
+ import { usePromptMove } from "./move"
58
+ import { readLocalAttachment } from "./local-attachment"
59
+ import { useLocation } from "../../context/location"
60
+
61
+ registerNeurocodeSpinner()
62
+
63
+ export type PromptProps = {
64
+ sessionID?: string
65
+ visible?: boolean
66
+ disabled?: boolean
67
+ onSubmit?: () => void
68
+ ref?: (ref: PromptRef | undefined) => void
69
+ hint?: JSX.Element
70
+ right?: JSX.Element
71
+ showPlaceholder?: boolean
72
+ placeholders?: {
73
+ normal?: string[]
74
+ shell?: string[]
75
+ }
76
+ }
77
+
78
+ function pastedFilepath(value: string, platform: string) {
79
+ const raw = value.replace(/^['"]+|['"]+$/g, "")
80
+ if (raw.startsWith("file://")) {
81
+ try {
82
+ return fileURLToPath(raw)
83
+ } catch {}
84
+ }
85
+ if (platform === "win32") return raw
86
+ return raw.replace(/\\(.)/g, "$1")
87
+ }
88
+
89
+ export type PromptRef = {
90
+ focused: boolean
91
+ current: PromptInfo
92
+ set(prompt: PromptInfo): void
93
+ reset(): void
94
+ blur(): void
95
+ focus(): void
96
+ submit(): void
97
+ }
98
+
99
+ const money = new Intl.NumberFormat("en-US", {
100
+ style: "currency",
101
+ currency: "USD",
102
+ })
103
+
104
+ const DRAFT_RETENTION_MIN_CHARS = 20
105
+
106
+ function randomIndex(count: number) {
107
+ if (count <= 0) return 0
108
+ return Math.floor(Math.random() * count)
109
+ }
110
+
111
+ function fadeColor(color: RGBA, alpha: number) {
112
+ return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha)
113
+ }
114
+
115
+ function hasEditorRangeSelection(selection: EditorSelection["ranges"][number]) {
116
+ return (
117
+ selection.selection.start.line !== selection.selection.end.line ||
118
+ selection.selection.start.character !== selection.selection.end.character
119
+ )
120
+ }
121
+
122
+ function getEditorRangeLabel(selection: EditorSelection["ranges"][number]) {
123
+ if (!hasEditorRangeSelection(selection)) return
124
+ if (selection.selection.start.line === selection.selection.end.line) return `#${selection.selection.start.line}`
125
+ return `#${selection.selection.start.line}-${selection.selection.end.line}`
126
+ }
127
+
128
+ function formatEditorContext(selection: EditorSelection) {
129
+ const selected = selection.ranges.filter(hasEditorRangeSelection)
130
+ if (selected.length === 0)
131
+ return `<system-reminder>Note: The user opened the file "${selection.filePath}". This may or may not be relevant to the current task.</system-reminder>\n`
132
+
133
+ const ranges = selected.map((range, index) => {
134
+ const prefix = selected.length > 1 ? `Selection ${index + 1}: ` : ""
135
+ return `Note: The user selected ${prefix}${getEditorRangeLabel(range)} from "${selection.filePath}". \`\`\`${range.text}\`\`\`\n\n`
136
+ })
137
+
138
+ return `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\n`
139
+ }
140
+
141
+ let stashed: { prompt: PromptInfo; cursor: number } | undefined
142
+
143
+ export function Prompt(props: PromptProps) {
144
+ let input: TextareaRenderable
145
+ let anchor: BoxRenderable
146
+ const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
147
+
148
+ const leader = useLeaderActive()
149
+ const local = useLocal()
150
+ const args = useArgs()
151
+ const paths = useTuiPaths()
152
+ const location = useLocation()
153
+ const terminalEnvironment = useTuiTerminalEnvironment()
154
+ const clipboard = useClipboard()
155
+ const sdk = useSDK()
156
+ const editor = useEditorContext()
157
+ const route = useRoute()
158
+ const project = useProject()
159
+ const sync = useSync()
160
+ const tuiConfig = useTuiConfig()
161
+ const dialog = useDialog()
162
+ const toast = useToast()
163
+ const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
164
+ const history = usePromptHistory()
165
+ const stash = usePromptStash()
166
+ const keymap = useNeurocodeKeymap()
167
+ const agentShortcut = useCommandShortcut("agent.cycle")
168
+ const paletteShortcut = useCommandShortcut("command.palette.show")
169
+ const renderer = useRenderer()
170
+ const exit = useExit()
171
+ const dimensions = useTerminalDimensions()
172
+ const { theme, syntax } = useTheme()
173
+ const kv = useKV()
174
+ const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
175
+ const list = createMemo(() => props.placeholders?.normal ?? [])
176
+ const shell = createMemo(() => props.placeholders?.shell ?? [])
177
+ const fileContextEnabled = createMemo(() => kv.get("file_context_enabled", true))
178
+ const [dismissedEditorSelectionKey, setDismissedEditorSelectionKey] = createSignal<string>()
179
+ const editorContext = createMemo(() => {
180
+ const selection = fileContextEnabled() ? editor.selection() : undefined
181
+ if (!selection) return
182
+ return editorSelectionKey(selection) === dismissedEditorSelectionKey() ? undefined : selection
183
+ })
184
+ const editorPath = createMemo(() => editorContext()?.filePath)
185
+ const editorSelectionLabel = createMemo(() => {
186
+ const ranges = editorContext()?.ranges
187
+ if (!ranges) return
188
+ const first = ranges.find(hasEditorRangeSelection) ?? ranges[0]
189
+ if (!first) return
190
+ return [getEditorRangeLabel(first), ranges.length > 1 ? `+${ranges.length - 1}` : undefined]
191
+ .filter(Boolean)
192
+ .join(" ")
193
+ })
194
+ const editorFileLabel = createMemo(() => {
195
+ const value = editorPath()
196
+ if (!value) return
197
+ const filename = path.basename(value)
198
+ const file = /^index\.[^./]+$/.test(filename)
199
+ ? [path.basename(path.dirname(value)), filename].filter(Boolean).join("/")
200
+ : filename
201
+ return `${file.split(path.sep).join("/")}${editorSelectionLabel() ?? ""}`
202
+ })
203
+ const editorFileLabelDisplay = createMemo(() => {
204
+ const file = editorFileLabel()
205
+ if (!file) return
206
+ return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
207
+ })
208
+ const editorContextLabelState = createMemo(() => editor.labelState())
209
+ const [auto, setAuto] = createSignal<AutocompleteRef>()
210
+ const workspace = usePromptWorkspace(props.sessionID)
211
+ const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID })
212
+ const [cursorVersion, setCursorVersion] = createSignal(0)
213
+ const currentProviderLabel = createMemo(() => local.model.parsed().provider)
214
+ const hasRightContent = createMemo(() => Boolean(props.right))
215
+
216
+ function promptModelWarning() {
217
+ toast.show({
218
+ variant: "warning",
219
+ message: "Connect a provider to send prompts",
220
+ duration: 3000,
221
+ })
222
+ if (sync.data.provider.length === 0) {
223
+ dialog.replace(() => <DialogProviderConnect />)
224
+ }
225
+ }
226
+
227
+ function dismissEditorContext() {
228
+ setDismissedEditorSelectionKey(editorSelectionKey(editorContext()))
229
+ editor.clearSelection()
230
+ }
231
+ const fileStyleId = syntax().getStyleId("extmark.file")!
232
+ const agentStyleId = syntax().getStyleId("extmark.agent")!
233
+ const pasteStyleId = syntax().getStyleId("extmark.paste")!
234
+ let promptPartTypeId = 0
235
+ const event = useEvent()
236
+
237
+ event.on("tui.prompt.append", (evt, { workspace }) => {
238
+ if (workspace !== project.workspace.current()) return
239
+ if (!input || input.isDestroyed) return
240
+ input.insertText(evt.properties.text)
241
+ setTimeout(() => {
242
+ // setTimeout is a workaround and needs to be addressed properly
243
+ if (!input || input.isDestroyed) return
244
+ input.getLayoutNode().markDirty()
245
+ input.gotoBufferEnd()
246
+ renderer.requestRender()
247
+ }, 0)
248
+ })
249
+
250
+ createEffect(() => {
251
+ if (!input || input.isDestroyed) return
252
+ if (props.disabled) input.cursorColor = theme.backgroundElement
253
+ if (!props.disabled) input.cursorColor = theme.text
254
+ })
255
+
256
+ const lastUserMessage = createMemo(() => {
257
+ if (!props.sessionID) return undefined
258
+ const messages = sync.data.message[props.sessionID]
259
+ if (!messages) return undefined
260
+ return messages.findLast((m): m is UserMessage => m.role === "user")
261
+ })
262
+
263
+ const usage = createMemo(() => {
264
+ if (!props.sessionID) return
265
+ const session = sync.session.get(props.sessionID)
266
+ const msg = sync.data.message[props.sessionID] ?? []
267
+ const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
268
+ if (!last) return
269
+
270
+ const tokens =
271
+ last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
272
+ if (tokens <= 0) return
273
+
274
+ const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
275
+ const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
276
+ const cost = session?.cost ?? 0
277
+ return {
278
+ context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
279
+ cost: cost > 0 ? money.format(cost) : undefined,
280
+ }
281
+ })
282
+
283
+ const [store, setStore] = createStore<{
284
+ prompt: PromptInfo
285
+ mode: "normal" | "shell"
286
+ extmarkToPartIndex: Map<number, number>
287
+ interrupt: number
288
+ placeholder: number
289
+ }>({
290
+ placeholder: randomIndex(list().length),
291
+ prompt: {
292
+ input: "",
293
+ parts: [],
294
+ },
295
+ mode: "normal",
296
+ extmarkToPartIndex: new Map(),
297
+ interrupt: 0,
298
+ })
299
+
300
+ createEffect(
301
+ on(
302
+ () => props.sessionID,
303
+ () => {
304
+ setStore("placeholder", randomIndex(list().length))
305
+ },
306
+ { defer: true },
307
+ ),
308
+ )
309
+
310
+ // Initialize agent/model/variant from last user message when session changes
311
+ let syncedSessionID: string | undefined
312
+ createEffect(() => {
313
+ const sessionID = props.sessionID
314
+ const msg = lastUserMessage()
315
+
316
+ if (sessionID !== syncedSessionID) {
317
+ if (!sessionID || !msg) return
318
+
319
+ syncedSessionID = sessionID
320
+
321
+ // Only set agent if it's a primary agent (not a subagent)
322
+ const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent)
323
+ if (msg.agent && isPrimaryAgent) {
324
+ // Keep command line --agent if specified.
325
+ if (!args.agent) local.agent.set(msg.agent)
326
+ if (msg.model) {
327
+ local.model.set(msg.model)
328
+ local.model.variant.set(msg.model.variant)
329
+ }
330
+ }
331
+ }
332
+ })
333
+
334
+ const promptCommands = createMemo(() =>
335
+ [
336
+ {
337
+ title: "Clear prompt",
338
+ name: "prompt.clear",
339
+ category: "Prompt",
340
+ hidden: true,
341
+ run: () => {
342
+ clearPrompt()
343
+ dialog.clear()
344
+ },
345
+ },
346
+ {
347
+ title: "Submit prompt",
348
+ name: "prompt.submit",
349
+ category: "Prompt",
350
+ hidden: true,
351
+ run: async () => {
352
+ if (!input.focused) return
353
+ const handled = await submit()
354
+ if (!handled) return
355
+
356
+ dialog.clear()
357
+ },
358
+ },
359
+ {
360
+ title: "Remove editor context",
361
+ name: "prompt.editor_context.clear",
362
+ category: "Prompt",
363
+ enabled: Boolean(editorContext()),
364
+ run: () => {
365
+ dismissEditorContext()
366
+ dialog.clear()
367
+ },
368
+ },
369
+ {
370
+ title: "Paste",
371
+ name: "prompt.paste",
372
+ category: "Prompt",
373
+ hidden: true,
374
+ run: async (ctx: CommandContext<Renderable, KeyEvent>) => {
375
+ ctx.event.preventDefault()
376
+ ctx.event.stopPropagation()
377
+ const content = await clipboard.read?.()
378
+ if (content?.mime.startsWith("image/")) {
379
+ await pasteAttachment({
380
+ filename: "clipboard",
381
+ mime: content.mime,
382
+ content: content.data,
383
+ })
384
+ return
385
+ }
386
+ if (content?.mime === "text/plain") {
387
+ await pasteInputText(content.data)
388
+ }
389
+ },
390
+ },
391
+ {
392
+ title: "Interrupt session",
393
+ name: "session.interrupt",
394
+ category: "Session",
395
+ hidden: true,
396
+ enabled: status().type !== "idle",
397
+ run: () => {
398
+ if (auto()?.visible) return
399
+ if (!input.focused) return
400
+ // TODO: this should be its own command
401
+ if (store.mode === "shell") {
402
+ setStore("mode", "normal")
403
+ return
404
+ }
405
+ if (!props.sessionID) return
406
+
407
+ setStore("interrupt", store.interrupt + 1)
408
+
409
+ setTimeout(() => {
410
+ setStore("interrupt", 0)
411
+ }, 5000)
412
+
413
+ if (store.interrupt >= 2) {
414
+ void sdk.client.session.abort({
415
+ sessionID: props.sessionID,
416
+ })
417
+ setStore("interrupt", 0)
418
+ }
419
+ dialog.clear()
420
+ },
421
+ },
422
+ {
423
+ title: "Open editor",
424
+ category: "Session",
425
+ name: "prompt.editor",
426
+ slashName: "editor",
427
+ run: async () => {
428
+ dialog.clear()
429
+
430
+ // replace summarized text parts with the actual text
431
+ const text = store.prompt.parts
432
+ .filter((p) => p.type === "text")
433
+ .reduce((acc, p) => {
434
+ if (!p.source) return acc
435
+ return acc.replace(p.source.text.value, p.text)
436
+ }, store.prompt.input)
437
+
438
+ const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
439
+
440
+ const value = text
441
+ const content = await openEditor({
442
+ renderer,
443
+ value,
444
+ cwd:
445
+ (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
446
+ project.instance.directory() ||
447
+ paths.cwd,
448
+ })
449
+ if (!content) return
450
+ const normalized = normalizePromptContent(content)
451
+
452
+ input.setText(normalized)
453
+
454
+ // Update positions for nonTextParts based on their location in new content
455
+ // Filter out parts whose virtual text was deleted
456
+ // this handles a case where the user edits the text in the editor
457
+ // such that the virtual text moves around or is deleted
458
+ const updatedNonTextParts = nonTextParts
459
+ .map((part) => {
460
+ let virtualText = ""
461
+ if (part.type === "file" && part.source?.text) {
462
+ virtualText = part.source.text.value
463
+ } else if (part.type === "agent" && part.source) {
464
+ virtualText = part.source.value
465
+ }
466
+
467
+ if (!virtualText) return part
468
+
469
+ const newStart = normalized.indexOf(virtualText)
470
+ // if the virtual text is deleted, remove the part
471
+ if (newStart === -1) return null
472
+
473
+ const newEnd = newStart + virtualText.length
474
+
475
+ if (part.type === "file" && part.source?.text) {
476
+ return {
477
+ ...part,
478
+ source: {
479
+ ...part.source,
480
+ text: {
481
+ ...part.source.text,
482
+ start: newStart,
483
+ end: newEnd,
484
+ },
485
+ },
486
+ }
487
+ }
488
+
489
+ if (part.type === "agent" && part.source) {
490
+ return {
491
+ ...part,
492
+ source: {
493
+ ...part.source,
494
+ start: newStart,
495
+ end: newEnd,
496
+ },
497
+ }
498
+ }
499
+
500
+ return part
501
+ })
502
+ .filter((part) => part !== null)
503
+
504
+ setStore("prompt", {
505
+ input: normalized,
506
+ // keep only the non-text parts because the text parts were
507
+ // already expanded inline
508
+ parts: updatedNonTextParts,
509
+ })
510
+ restoreExtmarksFromParts(updatedNonTextParts)
511
+ input.cursorOffset = Bun.stringWidth(normalized)
512
+ },
513
+ },
514
+ {
515
+ title: "Skills",
516
+ name: "prompt.skills",
517
+ category: "Prompt",
518
+ slashName: "skills",
519
+ run: () => {
520
+ dialog.replace(() => (
521
+ <DialogSkill
522
+ onSelect={(skill) => {
523
+ input.setText(`/${skill} `)
524
+ setStore("prompt", {
525
+ input: `/${skill} `,
526
+ parts: [],
527
+ })
528
+ input.gotoBufferEnd()
529
+ }}
530
+ />
531
+ ))
532
+ },
533
+ },
534
+ {
535
+ title: "Warp",
536
+ desc: "Change the workspace for the session",
537
+ name: "workspace.set",
538
+ category: "Session",
539
+ enabled: Flag.NEUROCODE_EXPERIMENTAL_WORKSPACES,
540
+ slashName: "warp",
541
+ run: () => {
542
+ workspace.open()
543
+ },
544
+ },
545
+ {
546
+ title: "Move session",
547
+ desc: "Move to another project dir",
548
+ name: "session.move",
549
+ category: "Session",
550
+ slashName: "move",
551
+ run: () => {
552
+ move.open()
553
+ },
554
+ },
555
+ ].map((entry) => ({
556
+ namespace: "palette",
557
+ ...entry,
558
+ })),
559
+ )
560
+
561
+ useBindings(() => ({
562
+ commands: promptCommands(),
563
+ }))
564
+
565
+ useBindings(() => ({
566
+ mode: NEUROCODE_BASE_MODE,
567
+ bindings: tuiConfig.keybinds.gather("prompt.palette", [
568
+ "prompt.submit",
569
+ "prompt.editor",
570
+ "prompt.editor_context.clear",
571
+ "prompt.stash",
572
+ "prompt.stash.pop",
573
+ "prompt.stash.list",
574
+ "prompt.skills",
575
+ "session.interrupt",
576
+ "workspace.set",
577
+ "session.move",
578
+ ]),
579
+ }))
580
+
581
+ const ref: PromptRef = {
582
+ get focused() {
583
+ return input.focused
584
+ },
585
+ get current() {
586
+ return store.prompt
587
+ },
588
+ focus() {
589
+ input.focus()
590
+ },
591
+ blur() {
592
+ input.blur()
593
+ },
594
+ set(prompt) {
595
+ input.setText(prompt.input)
596
+ setStore("prompt", prompt)
597
+ restoreExtmarksFromParts(prompt.parts)
598
+ input.gotoBufferEnd()
599
+ },
600
+ reset() {
601
+ input.clear()
602
+ input.extmarks.clear()
603
+ setStore("prompt", {
604
+ input: "",
605
+ parts: [],
606
+ })
607
+ setStore("extmarkToPartIndex", new Map())
608
+ },
609
+ submit() {
610
+ void submit()
611
+ },
612
+ }
613
+
614
+ onMount(() => {
615
+ const saved = stashed
616
+ stashed = undefined
617
+ if (store.prompt.input) return
618
+ if (saved && saved.prompt.input) {
619
+ input.setText(saved.prompt.input)
620
+ setStore("prompt", saved.prompt)
621
+ restoreExtmarksFromParts(saved.prompt.parts)
622
+ input.cursorOffset = saved.cursor
623
+ }
624
+ })
625
+
626
+ onCleanup(() => {
627
+ if (store.prompt.input) {
628
+ stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
629
+ }
630
+ setInputTarget(undefined)
631
+ props.ref?.(undefined)
632
+ })
633
+
634
+ createEffect(() => {
635
+ if (!input || input.isDestroyed) return
636
+ if (props.visible === false || dialog.stack.length > 0) {
637
+ if (input.focused) input.blur()
638
+ return
639
+ }
640
+
641
+ // Slot/plugin updates can remount the background prompt while a dialog is open.
642
+ // Keep focus with the dialog and let the prompt reclaim it after the dialog closes.
643
+ if (!input.focused) input.focus()
644
+ })
645
+
646
+ createEffect(() => {
647
+ if (!input || input.isDestroyed) return
648
+ input.traits = {
649
+ ...input.traits,
650
+ ...computePromptTraits({
651
+ mode: store.mode,
652
+ autocompleteVisible: !!auto()?.visible,
653
+ }),
654
+ }
655
+ })
656
+
657
+ function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
658
+ input.extmarks.clear()
659
+ setStore("extmarkToPartIndex", new Map())
660
+
661
+ parts.forEach((part, partIndex) => {
662
+ let start = 0
663
+ let end = 0
664
+ let virtualText = ""
665
+ let styleId: number | undefined
666
+
667
+ if (part.type === "file" && part.source?.text) {
668
+ start = part.source.text.start
669
+ end = part.source.text.end
670
+ virtualText = part.source.text.value
671
+ styleId = fileStyleId
672
+ } else if (part.type === "agent" && part.source) {
673
+ start = part.source.start
674
+ end = part.source.end
675
+ virtualText = part.source.value
676
+ styleId = agentStyleId
677
+ } else if (part.type === "text" && part.source?.text) {
678
+ start = part.source.text.start
679
+ end = part.source.text.end
680
+ virtualText = part.source.text.value
681
+ styleId = pasteStyleId
682
+ }
683
+
684
+ if (virtualText) {
685
+ const extmarkId = input.extmarks.create({
686
+ start,
687
+ end,
688
+ virtual: true,
689
+ styleId,
690
+ typeId: promptPartTypeId,
691
+ })
692
+ setStore("extmarkToPartIndex", (map: Map<number, number>) => {
693
+ const newMap = new Map(map)
694
+ newMap.set(extmarkId, partIndex)
695
+ return newMap
696
+ })
697
+ }
698
+ })
699
+ }
700
+
701
+ function syncExtmarksWithPromptParts() {
702
+ const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
703
+ setStore(
704
+ produce((draft) => {
705
+ const newMap = new Map<number, number>()
706
+ const newParts: typeof draft.prompt.parts = []
707
+
708
+ for (const extmark of allExtmarks) {
709
+ const partIndex = draft.extmarkToPartIndex.get(extmark.id)
710
+ if (partIndex !== undefined) {
711
+ const part = draft.prompt.parts[partIndex]
712
+ if (part) {
713
+ if (part.type === "agent" && part.source) {
714
+ part.source.start = extmark.start
715
+ part.source.end = extmark.end
716
+ } else if (part.type === "file" && part.source?.text) {
717
+ part.source.text.start = extmark.start
718
+ part.source.text.end = extmark.end
719
+ } else if (part.type === "text" && part.source?.text) {
720
+ part.source.text.start = extmark.start
721
+ part.source.text.end = extmark.end
722
+ }
723
+ newMap.set(extmark.id, newParts.length)
724
+ newParts.push(part)
725
+ }
726
+ }
727
+ }
728
+
729
+ draft.extmarkToPartIndex = newMap
730
+ draft.prompt.parts = newParts
731
+ }),
732
+ )
733
+ }
734
+
735
+ const stashCommands = createMemo(() =>
736
+ [
737
+ {
738
+ title: "Stash prompt",
739
+ name: "prompt.stash",
740
+ category: "Prompt",
741
+ enabled: !!store.prompt.input,
742
+ run: () => {
743
+ if (!store.prompt.input) return
744
+ stash.push({
745
+ input: store.prompt.input,
746
+ parts: store.prompt.parts,
747
+ })
748
+ input.extmarks.clear()
749
+ input.clear()
750
+ setStore("prompt", { input: "", parts: [] })
751
+ setStore("extmarkToPartIndex", new Map())
752
+ dialog.clear()
753
+ },
754
+ },
755
+ {
756
+ title: "Stash pop",
757
+ name: "prompt.stash.pop",
758
+ category: "Prompt",
759
+ enabled: stash.list().length > 0,
760
+ run: () => {
761
+ const entry = stash.pop()
762
+ if (entry) {
763
+ input.setText(entry.input)
764
+ setStore("prompt", { input: entry.input, parts: entry.parts })
765
+ restoreExtmarksFromParts(entry.parts)
766
+ input.gotoBufferEnd()
767
+ }
768
+ dialog.clear()
769
+ },
770
+ },
771
+ {
772
+ title: "Stash list",
773
+ name: "prompt.stash.list",
774
+ category: "Prompt",
775
+ enabled: stash.list().length > 0,
776
+ run: () => {
777
+ dialog.replace(() => (
778
+ <DialogStash
779
+ onSelect={(entry) => {
780
+ input.setText(entry.input)
781
+ setStore("prompt", { input: entry.input, parts: entry.parts })
782
+ restoreExtmarksFromParts(entry.parts)
783
+ input.gotoBufferEnd()
784
+ }}
785
+ />
786
+ ))
787
+ },
788
+ },
789
+ ].map((entry) => ({
790
+ namespace: "palette",
791
+ ...entry,
792
+ })),
793
+ )
794
+
795
+ useBindings(() => ({
796
+ commands: stashCommands(),
797
+ }))
798
+
799
+ useBindings(() => {
800
+ return {
801
+ target: inputTarget,
802
+ enabled: inputTarget() !== undefined && !props.disabled,
803
+ bindings: tuiConfig.keybinds.get("prompt.paste"),
804
+ }
805
+ })
806
+
807
+ useBindings(() => {
808
+ return {
809
+ target: inputTarget,
810
+ enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "",
811
+ bindings: tuiConfig.keybinds.get("prompt.clear"),
812
+ }
813
+ })
814
+
815
+ useBindings(() => {
816
+ return {
817
+ target: inputTarget,
818
+ enabled: (() => {
819
+ cursorVersion()
820
+ return (
821
+ inputTarget() !== undefined &&
822
+ !props.disabled &&
823
+ store.mode === "normal" &&
824
+ !auto()?.visible &&
825
+ input?.visualCursor.offset === 0
826
+ )
827
+ })(),
828
+ bindings: [
829
+ {
830
+ key: "!",
831
+ desc: "Shell mode",
832
+ group: "Prompt",
833
+ cmd: () => {
834
+ setStore("placeholder", randomIndex(shell().length))
835
+ setStore("mode", "shell")
836
+ },
837
+ },
838
+ ],
839
+ }
840
+ })
841
+
842
+ useBindings(() => {
843
+ return {
844
+ target: inputTarget,
845
+ enabled: inputTarget() !== undefined && store.mode === "shell",
846
+ bindings: [{ key: "escape", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }],
847
+ }
848
+ })
849
+
850
+ useBindings(() => {
851
+ return {
852
+ target: inputTarget,
853
+ enabled: (() => {
854
+ cursorVersion()
855
+ return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
856
+ })(),
857
+ bindings: [{ key: "backspace", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }],
858
+ }
859
+ })
860
+
861
+ useBindings(() => {
862
+ return {
863
+ target: inputTarget,
864
+ enabled: (() => {
865
+ cursorVersion()
866
+ return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
867
+ })(),
868
+ commands: [
869
+ {
870
+ name: "prompt.history.previous",
871
+ title: "Previous prompt history",
872
+ category: "Prompt",
873
+ run() {
874
+ if (input.cursorOffset !== 0) {
875
+ if (input.scrollY + input.visualCursor.visualRow === 0) input.cursorOffset = 0
876
+ return false
877
+ }
878
+
879
+ const item = history.move(-1, input.plainText)
880
+ if (!item) return false
881
+ input.setText(item.input)
882
+ setStore("prompt", item)
883
+ setStore("mode", item.mode ?? "normal")
884
+ restoreExtmarksFromParts(item.parts)
885
+ input.cursorOffset = 0
886
+ },
887
+ },
888
+ ],
889
+ bindings: tuiConfig.keybinds.get("prompt.history.previous"),
890
+ }
891
+ })
892
+
893
+ useBindings(() => {
894
+ return {
895
+ target: inputTarget,
896
+ enabled: (() => {
897
+ cursorVersion()
898
+ return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
899
+ })(),
900
+ commands: [
901
+ {
902
+ name: "prompt.history.next",
903
+ title: "Next prompt history",
904
+ category: "Prompt",
905
+ run() {
906
+ if (input.cursorOffset !== input.plainText.length) {
907
+ if (
908
+ input.scrollY + input.visualCursor.visualRow ===
909
+ Math.max(0, input.editorView.getTotalVirtualLineCount() - 1)
910
+ )
911
+ input.cursorOffset = input.plainText.length
912
+ return false
913
+ }
914
+
915
+ const item = history.move(1, input.plainText)
916
+ if (!item) return false
917
+ input.setText(item.input)
918
+ setStore("prompt", item)
919
+ setStore("mode", item.mode ?? "normal")
920
+ restoreExtmarksFromParts(item.parts)
921
+ input.cursorOffset = input.plainText.length
922
+ },
923
+ },
924
+ ],
925
+ bindings: tuiConfig.keybinds.get("prompt.history.next"),
926
+ }
927
+ })
928
+
929
+ let submitting = false
930
+ async function submit() {
931
+ // Prevent overlapping invocations (e.g. a double-pressed Enter, or the
932
+ // input's native onSubmit racing another dispatch). Without this guard,
933
+ // a second call slips past the empty-input check before the first call
934
+ // clears `store.prompt.input`, then awaits its own `session.create` and
935
+ // ultimately reads the now-empty store — sending a phantom empty prompt
936
+ // to a freshly created session.
937
+ if (submitting) return false
938
+ submitting = true
939
+ try {
940
+ return await submitInner()
941
+ } finally {
942
+ submitting = false
943
+ }
944
+ }
945
+
946
+ async function submitInner() {
947
+ workspace.clearNotice()
948
+
949
+ // IME: double-defer may fire before onContentChange flushes the last
950
+ // composed character (e.g. Korean hangul) to the store, so read
951
+ // plainText directly and sync before any downstream reads.
952
+ if (input && !input.isDestroyed && input.plainText !== store.prompt.input) {
953
+ setStore("prompt", "input", input.plainText)
954
+ syncExtmarksWithPromptParts()
955
+ }
956
+ if (props.disabled) return false
957
+ if (workspace.creating() || move.creating()) return false
958
+ if (auto()?.visible) return false
959
+ if (!store.prompt.input) return false
960
+ const agent = local.agent.current()
961
+ if (!agent) return false
962
+ const trimmed = store.prompt.input.trim()
963
+ if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
964
+ void exit()
965
+ return true
966
+ }
967
+ const selectedModel = local.model.current()
968
+ if (!selectedModel) {
969
+ void promptModelWarning()
970
+ return false
971
+ }
972
+
973
+ const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined
974
+ const workspaceID = workspaceSession?.workspaceID
975
+ const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined
976
+ if (props.sessionID && workspaceID && workspaceStatus !== "connected") {
977
+ dialog.replace(() => (
978
+ <DialogWorkspaceUnavailable
979
+ onRestore={() => {
980
+ workspace.open()
981
+ return false
982
+ }}
983
+ />
984
+ ))
985
+ return false
986
+ }
987
+
988
+ const variant = local.model.variant.current()
989
+ let sessionID = props.sessionID
990
+ let finishMoveProgress = false
991
+ if (sessionID == null) {
992
+ const selectedWorkspace = workspace.selection()
993
+ const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined
994
+
995
+ const directory = await move.getDirectory(store.prompt.input)
996
+ if (move.pending() && !directory) return false
997
+ finishMoveProgress = Boolean(move.progress())
998
+
999
+ const res = await sdk.client.session.create({
1000
+ directory,
1001
+ workspace: workspaceID,
1002
+ agent: agent.name,
1003
+ model: {
1004
+ providerID: selectedModel.providerID,
1005
+ id: selectedModel.modelID,
1006
+ variant,
1007
+ },
1008
+ })
1009
+
1010
+ if (res.error) {
1011
+ if (finishMoveProgress) move.finishSubmit()
1012
+ console.log("Creating a session failed:", res.error)
1013
+
1014
+ toast.show({
1015
+ message: "Creating a session failed. Open console for more details.",
1016
+ variant: "error",
1017
+ })
1018
+
1019
+ return true
1020
+ }
1021
+
1022
+ sessionID = res.data.id
1023
+ }
1024
+
1025
+ const inputText = expandTrackedPastedText(
1026
+ store.prompt.input,
1027
+ input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
1028
+ const partIndex = store.extmarkToPartIndex.get(extmark.id)
1029
+ const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex]
1030
+ if (part?.type !== "text") return []
1031
+ return [{ start: extmark.start, end: extmark.end, text: part.text }]
1032
+ }),
1033
+ )
1034
+
1035
+ // Filter out text parts (pasted content) since they're now expanded inline
1036
+ const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
1037
+
1038
+ // Capture mode before it gets reset
1039
+ const currentMode = store.mode
1040
+ const editorSelection = editorContext()
1041
+ const editorParts =
1042
+ editorSelection && editor.labelState() === "pending"
1043
+ ? [
1044
+ {
1045
+ type: "text" as const,
1046
+ text: formatEditorContext(editorSelection),
1047
+ synthetic: true,
1048
+ metadata: {
1049
+ kind: "editor_context",
1050
+ source: editorSelection.source ?? "editor",
1051
+ filePath: editorSelection.filePath,
1052
+ ranges: editorSelection.ranges,
1053
+ },
1054
+ },
1055
+ ]
1056
+ : []
1057
+
1058
+ if (store.mode === "shell") {
1059
+ move.startSubmit()
1060
+ void sdk.client.session.shell({
1061
+ sessionID,
1062
+ agent: agent.name,
1063
+ model: {
1064
+ providerID: selectedModel.providerID,
1065
+ modelID: selectedModel.modelID,
1066
+ },
1067
+ command: inputText,
1068
+ })
1069
+ setStore("mode", "normal")
1070
+ } else if (
1071
+ inputText.startsWith("/") &&
1072
+ sync.data.command.some((x) => x.name === inputText.split("\n")[0].split(" ")[0].slice(1))
1073
+ ) {
1074
+ move.startSubmit()
1075
+ // Parse command from first line, preserve multi-line content in arguments
1076
+ const firstLineEnd = inputText.indexOf("\n")
1077
+ const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
1078
+ const [command, ...firstLineArgs] = firstLine.split(" ")
1079
+ const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
1080
+ const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
1081
+
1082
+ void sdk.client.session.command({
1083
+ sessionID,
1084
+ command: command.slice(1),
1085
+ arguments: args,
1086
+ agent: agent.name,
1087
+ model: `${selectedModel.providerID}/${selectedModel.modelID}`,
1088
+ variant,
1089
+ parts: nonTextParts.filter((x) => x.type === "file"),
1090
+ })
1091
+ } else {
1092
+ move.startSubmit()
1093
+ sdk.client.session
1094
+ .prompt(
1095
+ {
1096
+ sessionID,
1097
+ ...selectedModel,
1098
+ agent: agent.name,
1099
+ model: selectedModel,
1100
+ variant,
1101
+ parts: [
1102
+ ...editorParts,
1103
+ {
1104
+ type: "text",
1105
+ text: inputText,
1106
+ },
1107
+ ...nonTextParts,
1108
+ ],
1109
+ },
1110
+ { throwOnError: true },
1111
+ )
1112
+ .catch((error) => {
1113
+ toast.show({
1114
+ title: "Failed to send prompt",
1115
+ message: errorMessage(error),
1116
+ variant: "error",
1117
+ })
1118
+ })
1119
+ if (editorParts.length > 0) editor.markSelectionSent()
1120
+ }
1121
+ history.append({
1122
+ ...store.prompt,
1123
+ mode: currentMode,
1124
+ })
1125
+ input.extmarks.clear()
1126
+ setStore("prompt", {
1127
+ input: "",
1128
+ parts: [],
1129
+ })
1130
+ setStore("extmarkToPartIndex", new Map())
1131
+ props.onSubmit?.()
1132
+
1133
+ // temporary hack to make sure the message is sent
1134
+ if (!props.sessionID) {
1135
+ if (editorParts.length > 0) editor.preserveSelectionFromNewSession()
1136
+ setTimeout(() => {
1137
+ route.navigate({
1138
+ type: "session",
1139
+ sessionID,
1140
+ })
1141
+ }, 50)
1142
+ }
1143
+ input.clear()
1144
+ if (finishMoveProgress) move.finishSubmit()
1145
+ return true
1146
+ }
1147
+
1148
+ function pasteText(text: string, virtualText: string) {
1149
+ const currentOffset = input.cursorOffset
1150
+ const extmarkStart = currentOffset
1151
+ const extmarkEnd = extmarkStart + promptOffsetWidth(virtualText)
1152
+
1153
+ input.insertText(virtualText + " ")
1154
+
1155
+ const extmarkId = input.extmarks.create({
1156
+ start: extmarkStart,
1157
+ end: extmarkEnd,
1158
+ virtual: true,
1159
+ styleId: pasteStyleId,
1160
+ typeId: promptPartTypeId,
1161
+ })
1162
+
1163
+ setStore(
1164
+ produce((draft) => {
1165
+ const partIndex = draft.prompt.parts.length
1166
+ draft.prompt.parts.push({
1167
+ type: "text" as const,
1168
+ text,
1169
+ source: {
1170
+ text: {
1171
+ start: extmarkStart,
1172
+ end: extmarkEnd,
1173
+ value: virtualText,
1174
+ },
1175
+ },
1176
+ })
1177
+ draft.extmarkToPartIndex.set(extmarkId, partIndex)
1178
+ }),
1179
+ )
1180
+ }
1181
+
1182
+ async function pasteInputText(text: string) {
1183
+ const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
1184
+ const pastedContent = normalizedText.trim()
1185
+ const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
1186
+ const isUrl = /^(https?):\/\//.test(filepath)
1187
+ if (!isUrl) {
1188
+ const attachment = await readLocalAttachment(filepath)
1189
+ const filename = path.basename(filepath)
1190
+ if (attachment?.type === "text") {
1191
+ pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`)
1192
+ return
1193
+ }
1194
+ if (attachment?.type === "binary") {
1195
+ await pasteAttachment({
1196
+ filename,
1197
+ filepath,
1198
+ mime: attachment.mime,
1199
+ content: Buffer.from(attachment.content).toString("base64"),
1200
+ })
1201
+ return
1202
+ }
1203
+ }
1204
+
1205
+ const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
1206
+ if (
1207
+ (lineCount >= 3 || pastedContent.length > 150) &&
1208
+ kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
1209
+ ) {
1210
+ pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
1211
+ return
1212
+ }
1213
+
1214
+ input.insertText(normalizedText)
1215
+
1216
+ setTimeout(() => {
1217
+ if (!input || input.isDestroyed) return
1218
+ input.getLayoutNode().markDirty()
1219
+ renderer.requestRender()
1220
+ }, 0)
1221
+ }
1222
+
1223
+ async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) {
1224
+ const currentOffset = input.cursorOffset
1225
+ const extmarkStart = currentOffset
1226
+ const pdf = file.mime === "application/pdf"
1227
+ const count = store.prompt.parts.filter((x) => {
1228
+ if (x.type !== "file") return false
1229
+ if (pdf) return x.mime === "application/pdf"
1230
+ return x.mime.startsWith("image/")
1231
+ }).length
1232
+ const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
1233
+ const extmarkEnd = extmarkStart + virtualText.length
1234
+ const textToInsert = virtualText + " "
1235
+
1236
+ input.insertText(textToInsert)
1237
+
1238
+ const extmarkId = input.extmarks.create({
1239
+ start: extmarkStart,
1240
+ end: extmarkEnd,
1241
+ virtual: true,
1242
+ styleId: pasteStyleId,
1243
+ typeId: promptPartTypeId,
1244
+ })
1245
+
1246
+ const part: Omit<FilePart, "id" | "messageID" | "sessionID"> = {
1247
+ type: "file" as const,
1248
+ mime: file.mime,
1249
+ filename: file.filename,
1250
+ url: `data:${file.mime};base64,${file.content}`,
1251
+ source: {
1252
+ type: "file",
1253
+ path: file.filepath ?? file.filename ?? "",
1254
+ text: {
1255
+ start: extmarkStart,
1256
+ end: extmarkEnd,
1257
+ value: virtualText,
1258
+ },
1259
+ },
1260
+ }
1261
+ setStore(
1262
+ produce((draft) => {
1263
+ const partIndex = draft.prompt.parts.length
1264
+ draft.prompt.parts.push(part)
1265
+ draft.extmarkToPartIndex.set(extmarkId, partIndex)
1266
+ }),
1267
+ )
1268
+ return
1269
+ }
1270
+
1271
+ function clearPrompt() {
1272
+ if (store.prompt.input.trim().length >= DRAFT_RETENTION_MIN_CHARS || store.prompt.parts.length > 0) {
1273
+ history.append({
1274
+ ...store.prompt,
1275
+ mode: store.mode,
1276
+ })
1277
+ }
1278
+ input.clear()
1279
+ input.extmarks.clear()
1280
+ setStore("prompt", {
1281
+ input: "",
1282
+ parts: [],
1283
+ })
1284
+ setStore("extmarkToPartIndex", new Map())
1285
+ }
1286
+
1287
+ const highlight = createMemo(() => {
1288
+ if (leader()) return theme.border
1289
+ if (store.mode === "shell") return theme.primary
1290
+ const agent = local.agent.current()
1291
+ if (!agent) return theme.border
1292
+ return local.agent.color(agent.name)
1293
+ })
1294
+
1295
+ const showVariant = createMemo(() => {
1296
+ const variants = local.model.variant.list()
1297
+ if (variants.length === 0) return false
1298
+ const current = local.model.variant.current()
1299
+ return !!current
1300
+ })
1301
+
1302
+ const agentMetaAlpha = createFadeIn(() => !!local.agent.current(), animationsEnabled)
1303
+ const modelMetaAlpha = createFadeIn(() => !!local.agent.current() && store.mode === "normal", animationsEnabled)
1304
+ const variantMetaAlpha = createFadeIn(
1305
+ () => !!local.agent.current() && store.mode === "normal" && showVariant(),
1306
+ animationsEnabled,
1307
+ )
1308
+ const borderHighlight = createMemo(() => tint(theme.border, highlight(), agentMetaAlpha()))
1309
+
1310
+ const placeholderText = createMemo(() => {
1311
+ if (props.showPlaceholder === false) return undefined
1312
+ if (store.mode === "shell") {
1313
+ if (!shell().length) return undefined
1314
+ const example = shell()[store.placeholder % shell().length]
1315
+ return `Run a command... "${example}"`
1316
+ }
1317
+ if (!list().length) return undefined
1318
+ return `Ask anything... "${list()[store.placeholder % list().length]}"`
1319
+ })
1320
+
1321
+ const spinnerDef = createMemo(() => {
1322
+ const agent =
1323
+ status().type !== "idle"
1324
+ ? (local.agent.list().find((a) => a.name === lastUserMessage()?.agent) ?? local.agent.current())
1325
+ : local.agent.current()
1326
+ const color = agent ? local.agent.color(agent.name) : theme.border
1327
+ return {
1328
+ frames: createFrames({
1329
+ color,
1330
+ style: "blocks",
1331
+ inactiveFactor: 0.6,
1332
+ // enableFading: false,
1333
+ minAlpha: 0.3,
1334
+ }),
1335
+ color: createColors({
1336
+ color,
1337
+ style: "blocks",
1338
+ inactiveFactor: 0.6,
1339
+ // enableFading: false,
1340
+ minAlpha: 0.3,
1341
+ }),
1342
+ }
1343
+ })
1344
+ const maxHeight = createMemo(() => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3)))
1345
+ const moveLabelWidth = createMemo(() => Math.max(12, Math.min(44, dimensions().width - 48)))
1346
+
1347
+ return (
1348
+ <>
1349
+ <box ref={(r: BoxRenderable) => (anchor = r)} visible={props.visible !== false} width="100%">
1350
+ <box
1351
+ width="100%"
1352
+ border={["left"]}
1353
+ borderColor={borderHighlight()}
1354
+ customBorderChars={{
1355
+ ...SplitBorder.customBorderChars,
1356
+ bottomLeft: "╹",
1357
+ }}
1358
+ >
1359
+ <box
1360
+ paddingLeft={2}
1361
+ paddingRight={2}
1362
+ paddingTop={1}
1363
+ flexShrink={0}
1364
+ backgroundColor={theme.backgroundElement}
1365
+ flexGrow={1}
1366
+ width="100%"
1367
+ >
1368
+ <textarea
1369
+ width="100%"
1370
+ placeholder={placeholderText()}
1371
+ placeholderColor={theme.textMuted}
1372
+ textColor={leader() ? theme.textMuted : theme.text}
1373
+ focusedTextColor={leader() ? theme.textMuted : theme.text}
1374
+ minHeight={1}
1375
+ maxHeight={maxHeight()}
1376
+ onContentChange={() => {
1377
+ const value = input.plainText
1378
+ setStore("prompt", "input", value)
1379
+ auto()?.onInput(value)
1380
+ syncExtmarksWithPromptParts()
1381
+ setCursorVersion((value) => value + 1)
1382
+ }}
1383
+ onCursorChange={() => setCursorVersion((value) => value + 1)}
1384
+ onKeyDown={(e: { preventDefault(): void }) => {
1385
+ if (props.disabled) {
1386
+ e.preventDefault()
1387
+ return
1388
+ }
1389
+ }}
1390
+ onSubmit={() => {
1391
+ // IME: double-defer so the last composed character (e.g. Korean
1392
+ // hangul) is flushed to plainText before we read it for submission.
1393
+ setTimeout(() => setTimeout(() => submit(), 0), 0)
1394
+ }}
1395
+ onPaste={async (event: PasteEvent) => {
1396
+ if (props.disabled) {
1397
+ event.preventDefault()
1398
+ return
1399
+ }
1400
+
1401
+ // Normalize line endings at the boundary
1402
+ // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
1403
+ // Replace CRLF first, then any remaining CR
1404
+ const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
1405
+ const pastedContent = normalizedText.trim()
1406
+
1407
+ // Windows Terminal <1.25 can surface image-only clipboard as an
1408
+ // empty bracketed paste. Windows Terminal 1.25+ does not.
1409
+ if (!pastedContent) {
1410
+ keymap.dispatchCommand("prompt.paste")
1411
+ return
1412
+ }
1413
+
1414
+ // Once we cross an async boundary below, the terminal may perform its
1415
+ // default paste unless we suppress it first and handle insertion ourselves.
1416
+ event.preventDefault()
1417
+
1418
+ await pasteInputText(normalizedText)
1419
+ }}
1420
+ ref={(r: TextareaRenderable) => {
1421
+ input = r
1422
+ Object.assign(r, {
1423
+ getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.parts),
1424
+ })
1425
+ setInputTarget(r)
1426
+ if (promptPartTypeId === 0) {
1427
+ promptPartTypeId = input.extmarks.registerType("prompt-part")
1428
+ }
1429
+ props.ref?.(ref)
1430
+ setTimeout(() => {
1431
+ // setTimeout is a workaround and needs to be addressed properly
1432
+ if (!input || input.isDestroyed) return
1433
+ input.cursorColor = theme.text
1434
+ }, 0)
1435
+ }}
1436
+ onMouseDown={(r: MouseEvent) => r.target?.focus()}
1437
+ focusedBackgroundColor={theme.backgroundElement}
1438
+ cursorColor={props.disabled ? theme.backgroundElement : theme.text}
1439
+ syntaxStyle={syntax()}
1440
+ />
1441
+ <box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
1442
+ <box flexDirection="row" gap={1}>
1443
+ <Show when={local.agent.current()} fallback={<box height={1} />}>
1444
+ {(agent) => (
1445
+ <>
1446
+ <text fg={fadeColor(highlight(), agentMetaAlpha())}>
1447
+ {store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)}
1448
+ </text>
1449
+ <Show when={store.mode === "normal" && local.permission.mode === "auto"}>
1450
+ <text fg={fadeColor(theme.textMuted, agentMetaAlpha())}>auto</text>
1451
+ </Show>
1452
+ <Show when={store.mode === "normal"}>
1453
+ <box flexDirection="row" gap={1}>
1454
+ <text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
1455
+ <text
1456
+ flexShrink={0}
1457
+ fg={fadeColor(leader() ? theme.textMuted : theme.text, modelMetaAlpha())}
1458
+ >
1459
+ {local.model.parsed().model}
1460
+ </text>
1461
+ <text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>{currentProviderLabel()}</text>
1462
+ <Show when={showVariant()}>
1463
+ <text fg={fadeColor(theme.textMuted, variantMetaAlpha())}>·</text>
1464
+ <text>
1465
+ <span style={{ fg: fadeColor(theme.warning, variantMetaAlpha()), bold: true }}>
1466
+ {local.model.variant.current()}
1467
+ </span>
1468
+ </text>
1469
+ </Show>
1470
+ </box>
1471
+ </Show>
1472
+ </>
1473
+ )}
1474
+ </Show>
1475
+ </box>
1476
+ <Show when={hasRightContent()}>
1477
+ <box flexDirection="row" gap={1} alignItems="center">
1478
+ {props.right}
1479
+ </box>
1480
+ </Show>
1481
+ </box>
1482
+ </box>
1483
+ </box>
1484
+ <box
1485
+ height={1}
1486
+ border={["left"]}
1487
+ borderColor={borderHighlight()}
1488
+ customBorderChars={{
1489
+ ...EmptyBorder,
1490
+ vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
1491
+ }}
1492
+ >
1493
+ <box
1494
+ height={1}
1495
+ border={["bottom"]}
1496
+ borderColor={theme.backgroundElement}
1497
+ customBorderChars={
1498
+ theme.backgroundElement.a !== 0
1499
+ ? {
1500
+ ...EmptyBorder,
1501
+ horizontal: "▀",
1502
+ }
1503
+ : {
1504
+ ...EmptyBorder,
1505
+ horizontal: " ",
1506
+ }
1507
+ }
1508
+ />
1509
+ </box>
1510
+ <box width="100%" flexDirection="row" justifyContent="space-between">
1511
+ <Switch>
1512
+ <Match when={status().type !== "idle"}>
1513
+ <box
1514
+ flexDirection="row"
1515
+ gap={1}
1516
+ flexGrow={1}
1517
+ justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
1518
+ >
1519
+ <box flexShrink={0} flexDirection="row" gap={1}>
1520
+ <box marginLeft={1}>
1521
+ <Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
1522
+ <spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
1523
+ </Show>
1524
+ </box>
1525
+ <box flexDirection="row" gap={1} flexShrink={0}>
1526
+ {(() => {
1527
+ const retry = createMemo(() => {
1528
+ const s = status()
1529
+ if (s.type !== "retry") return
1530
+ return s
1531
+ })
1532
+ const message = createMemo(() => {
1533
+ const r = retry()
1534
+ if (!r) return
1535
+ if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
1536
+ return "gemini is way too hot right now"
1537
+ if (r.message.length > 80) return r.message.slice(0, 80) + "..."
1538
+ return r.message
1539
+ })
1540
+ const isTruncated = createMemo(() => {
1541
+ const r = retry()
1542
+ if (!r) return false
1543
+ return r.message.length > 120
1544
+ })
1545
+ const [seconds, setSeconds] = createSignal(0)
1546
+ onMount(() => {
1547
+ const timer = setInterval(() => {
1548
+ const next = retry()?.next
1549
+ if (next) setSeconds(Math.round((next - Date.now()) / 1000))
1550
+ }, 1000)
1551
+
1552
+ onCleanup(() => {
1553
+ clearInterval(timer)
1554
+ })
1555
+ })
1556
+ const handleMessageClick = () => {
1557
+ const r = retry()
1558
+ if (!r) return
1559
+ if (isTruncated()) {
1560
+ void DialogAlert.show(dialog, "Retry Error", r.message)
1561
+ }
1562
+ }
1563
+
1564
+ const retryText = () => {
1565
+ const r = retry()
1566
+ if (!r) return ""
1567
+ const baseMessage = message()
1568
+ const truncatedHint = isTruncated() ? " (click to expand)" : ""
1569
+ const duration = formatDuration(seconds())
1570
+ const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
1571
+ return baseMessage + truncatedHint + retryInfo
1572
+ }
1573
+
1574
+ return (
1575
+ <Show when={retry()}>
1576
+ <box onMouseUp={handleMessageClick}>
1577
+ <text fg={theme.error}>{retryText()}</text>
1578
+ </box>
1579
+ </Show>
1580
+ )
1581
+ })()}
1582
+ </box>
1583
+ </box>
1584
+ <text fg={store.interrupt > 0 ? theme.primary : theme.text}>
1585
+ esc{" "}
1586
+ <span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
1587
+ {store.interrupt > 0 ? "again to interrupt" : "interrupt"}
1588
+ </span>
1589
+ </text>
1590
+ </box>
1591
+ </Match>
1592
+ <Match when={workspace.notice()}>
1593
+ {(notice) => (
1594
+ <box paddingLeft={3}>
1595
+ <text fg={theme.accent}>{notice()}</text>
1596
+ </box>
1597
+ )}
1598
+ </Match>
1599
+ <Match when={workspace.label()}>
1600
+ {(label) => (
1601
+ <box paddingLeft={3} flexDirection="row" gap={1}>
1602
+ <Show when={workspace.creating()}>
1603
+ <Spinner color={theme.accent} />
1604
+ </Show>
1605
+ <text fg={workspace.creating() ? theme.accent : theme.text}>
1606
+ {(() => {
1607
+ const item = label()
1608
+ if (item.type === "new") {
1609
+ if (workspace.creating())
1610
+ return `Creating ${item.workspaceType}${".".repeat(workspace.creatingDots())}`
1611
+ return (
1612
+ <>
1613
+ Workspace <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
1614
+ </>
1615
+ )
1616
+ }
1617
+ return (
1618
+ <>
1619
+ Workspace <span style={{ fg: theme.textMuted }}>{item.workspaceName}</span>
1620
+ </>
1621
+ )
1622
+ })()}
1623
+ </text>
1624
+ </box>
1625
+ )}
1626
+ </Match>
1627
+ <Match when={move.progress()}>
1628
+ {(progress) => (
1629
+ <box paddingLeft={3}>
1630
+ <Spinner color={theme.accent}>
1631
+ {progress()}
1632
+ <span style={{ fg: theme.textMuted }}>{".".repeat(move.creatingDots())}</span>
1633
+ </Spinner>
1634
+ </box>
1635
+ )}
1636
+ </Match>
1637
+ <Match when={move.pendingNew()}>
1638
+ <box paddingLeft={3}>
1639
+ <text fg={theme.accent}>(new working copy)</text>
1640
+ </box>
1641
+ </Match>
1642
+ <Match when={true}>
1643
+ {props.hint ?? (
1644
+ <Show when={props.sessionID}>
1645
+ <box marginLeft={1}>
1646
+ <text fg={theme.textMuted}>{location()?.directory ?? paths.cwd}</text>
1647
+ </box>
1648
+ </Show>
1649
+ )}
1650
+ </Match>
1651
+ </Switch>
1652
+ <Show when={status().type !== "retry"}>
1653
+ <box gap={2} flexDirection="row">
1654
+ <Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
1655
+ {(file) => (
1656
+ <text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text>
1657
+ )}
1658
+ </Show>
1659
+ <Switch>
1660
+ <Match when={store.mode === "normal"}>
1661
+ <Switch>
1662
+ <Match when={usage()}>
1663
+ {(item) => (
1664
+ <text fg={theme.textMuted} wrapMode="none">
1665
+ {[item().context, item().cost].filter(Boolean).join(" · ")}
1666
+ </text>
1667
+ )}
1668
+ </Match>
1669
+ <Match when={true}>
1670
+ <text fg={theme.text}>
1671
+ {agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
1672
+ </text>
1673
+ </Match>
1674
+ </Switch>
1675
+ <text fg={theme.text}>
1676
+ {paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
1677
+ </text>
1678
+ </Match>
1679
+ <Match when={store.mode === "shell"}>
1680
+ <text fg={theme.text}>
1681
+ esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
1682
+ </text>
1683
+ </Match>
1684
+ </Switch>
1685
+ </box>
1686
+ </Show>
1687
+ </box>
1688
+ </box>
1689
+ <Autocomplete
1690
+ sessionID={props.sessionID}
1691
+ ref={(r) => {
1692
+ setAuto(() => r)
1693
+ }}
1694
+ anchor={() => anchor}
1695
+ input={() => input}
1696
+ setPrompt={(cb) => {
1697
+ setStore("prompt", produce(cb))
1698
+ }}
1699
+ setExtmark={(partIndex, extmarkId) => {
1700
+ setStore("extmarkToPartIndex", (map: Map<number, number>) => {
1701
+ const newMap = new Map(map)
1702
+ newMap.set(extmarkId, partIndex)
1703
+ return newMap
1704
+ })
1705
+ }}
1706
+ value={store.prompt.input}
1707
+ fileStyleId={fileStyleId}
1708
+ agentStyleId={agentStyleId}
1709
+ promptPartTypeId={() => promptPartTypeId}
1710
+ />
1711
+ </>
1712
+ )
1713
+ }