@agentprojectcontext/apx 1.15.6 → 1.16.0

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