@neurocode-ai/session-ui 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 (114) hide show
  1. package/package.json +71 -0
  2. package/src/components/apply-patch-file.test.ts +43 -0
  3. package/src/components/apply-patch-file.ts +78 -0
  4. package/src/components/basic-tool.css +358 -0
  5. package/src/components/basic-tool.stories.tsx +133 -0
  6. package/src/components/basic-tool.tsx +343 -0
  7. package/src/components/dock-prompt.stories.tsx +62 -0
  8. package/src/components/dock-prompt.tsx +23 -0
  9. package/src/components/file-media.tsx +291 -0
  10. package/src/components/file-search.tsx +72 -0
  11. package/src/components/file-ssr.tsx +197 -0
  12. package/src/components/file.css +46 -0
  13. package/src/components/file.tsx +1202 -0
  14. package/src/components/line-comment-annotations.tsx +637 -0
  15. package/src/components/line-comment-styles.ts +292 -0
  16. package/src/components/line-comment.stories.tsx +115 -0
  17. package/src/components/line-comment.tsx +439 -0
  18. package/src/components/markdown-cache.tsx +78 -0
  19. package/src/components/markdown-code-state.test.ts +32 -0
  20. package/src/components/markdown-code-state.ts +22 -0
  21. package/src/components/markdown-inline-code-kind.test.ts +46 -0
  22. package/src/components/markdown-inline-code-kind.ts +1915 -0
  23. package/src/components/markdown-preload.test.ts +18 -0
  24. package/src/components/markdown-shiki.worker.ts +104 -0
  25. package/src/components/markdown-stream.test.ts +194 -0
  26. package/src/components/markdown-stream.ts +110 -0
  27. package/src/components/markdown-worker-protocol.test.ts +81 -0
  28. package/src/components/markdown-worker-protocol.ts +48 -0
  29. package/src/components/markdown-worker-queue.test.ts +49 -0
  30. package/src/components/markdown-worker-queue.ts +64 -0
  31. package/src/components/markdown-worker-transport.test.ts +56 -0
  32. package/src/components/markdown-worker-transport.ts +41 -0
  33. package/src/components/markdown-worker.ts +122 -0
  34. package/src/components/markdown.css +377 -0
  35. package/src/components/markdown.stories.tsx +53 -0
  36. package/src/components/markdown.tsx +678 -0
  37. package/src/components/message-file.test.ts +63 -0
  38. package/src/components/message-file.ts +34 -0
  39. package/src/components/message-nav.css +127 -0
  40. package/src/components/message-nav.stories.tsx +7 -0
  41. package/src/components/message-nav.tsx +102 -0
  42. package/src/components/message-part-text.ts +3 -0
  43. package/src/components/message-part.css +1569 -0
  44. package/src/components/message-part.stories.tsx +7 -0
  45. package/src/components/message-part.test.ts +28 -0
  46. package/src/components/message-part.tsx +2662 -0
  47. package/src/components/session-diff.test.ts +135 -0
  48. package/src/components/session-diff.ts +145 -0
  49. package/src/components/session-retry.tsx +74 -0
  50. package/src/components/session-review.css +247 -0
  51. package/src/components/session-review.stories.tsx +7 -0
  52. package/src/components/session-review.tsx +657 -0
  53. package/src/components/session-turn.css +241 -0
  54. package/src/components/session-turn.stories.tsx +7 -0
  55. package/src/components/session-turn.tsx +543 -0
  56. package/src/components/shell-submessage-motion.stories.tsx +346 -0
  57. package/src/components/shell-submessage.css +23 -0
  58. package/src/components/timeline-playground.stories.tsx +2090 -0
  59. package/src/components/tool-count-label.css +57 -0
  60. package/src/components/tool-count-label.tsx +58 -0
  61. package/src/components/tool-count-summary.css +102 -0
  62. package/src/components/tool-count-summary.stories.tsx +238 -0
  63. package/src/components/tool-count-summary.tsx +52 -0
  64. package/src/components/tool-error-card.css +91 -0
  65. package/src/components/tool-error-card.stories.tsx +92 -0
  66. package/src/components/tool-error-card.tsx +157 -0
  67. package/src/components/tool-status-title.css +89 -0
  68. package/src/components/tool-status-title.tsx +133 -0
  69. package/src/context/data.tsx +64 -0
  70. package/src/context/index.ts +1 -0
  71. package/src/pierre/comment-hover.ts +83 -0
  72. package/src/pierre/commented-lines.ts +91 -0
  73. package/src/pierre/diff-selection.ts +71 -0
  74. package/src/pierre/file-find.ts +485 -0
  75. package/src/pierre/file-runtime.ts +114 -0
  76. package/src/pierre/file-selection.ts +85 -0
  77. package/src/pierre/index.ts +186 -0
  78. package/src/pierre/media.ts +110 -0
  79. package/src/pierre/selection-bridge.ts +132 -0
  80. package/src/pierre/virtualizer.ts +100 -0
  81. package/src/pierre/worker.ts +52 -0
  82. package/src/styles/index.css +14 -0
  83. package/src/v2/components/attachment-card-v2.css +70 -0
  84. package/src/v2/components/attachment-card-v2.tsx +33 -0
  85. package/src/v2/components/basic-tool-v2.css +163 -0
  86. package/src/v2/components/basic-tool-v2.stories.tsx +137 -0
  87. package/src/v2/components/basic-tool-v2.tsx +139 -0
  88. package/src/v2/components/comment-card-v2.tsx +64 -0
  89. package/src/v2/components/line-comment-annotations-v2.tsx +220 -0
  90. package/src/v2/components/prompt-input/attachments.ts +266 -0
  91. package/src/v2/components/prompt-input/index.tsx +706 -0
  92. package/src/v2/components/prompt-input/interaction.ts +482 -0
  93. package/src/v2/components/prompt-input/machine.test.ts +164 -0
  94. package/src/v2/components/prompt-input/machine.ts +261 -0
  95. package/src/v2/components/prompt-input/prompt-input.stories.tsx +221 -0
  96. package/src/v2/components/prompt-input/store.test.ts +116 -0
  97. package/src/v2/components/prompt-input/store.ts +152 -0
  98. package/src/v2/components/prompt-input/types.ts +106 -0
  99. package/src/v2/components/session-file-panel-v2.tsx +43 -0
  100. package/src/v2/components/session-progress-indicator-v2.css +875 -0
  101. package/src/v2/components/session-progress-indicator-v2.stories.tsx +66 -0
  102. package/src/v2/components/session-progress-indicator-v2.tsx +32 -0
  103. package/src/v2/components/session-review-empty-changes-v2.tsx +17 -0
  104. package/src/v2/components/session-review-empty-no-git-v2.tsx +28 -0
  105. package/src/v2/components/session-review-file-preview-v2-virtualize.test.ts +13 -0
  106. package/src/v2/components/session-review-file-preview-v2-virtualize.ts +5 -0
  107. package/src/v2/components/session-review-file-preview-v2.tsx +290 -0
  108. package/src/v2/components/session-review-v2.css +458 -0
  109. package/src/v2/components/session-review-v2.tsx +340 -0
  110. package/src/v2/components/tool-error-card-v2.css +200 -0
  111. package/src/v2/components/tool-error-card-v2.stories.tsx +91 -0
  112. package/src/v2/components/tool-error-card-v2.tsx +166 -0
  113. package/sst-env.d.ts +10 -0
  114. package/tsconfig.json +19 -0
@@ -0,0 +1,261 @@
1
+ import type { PromptInputV2HistoryEntry, PromptInputV2PersistedState, PromptInputV2Suggestion } from "./types"
2
+
3
+ export type PromptInputV2InteractionState = {
4
+ mode: "normal" | "shell"
5
+ popover:
6
+ | { type: "closed" }
7
+ | { type: "context"; query: string; activeID?: string }
8
+ | { type: "command-inline"; query: string; activeID?: string }
9
+ | { type: "command-menu"; query: string; activeID?: string }
10
+ drag: "idle" | "active"
11
+ focus: "editor" | "command-search" | "external"
12
+ activeContextID?: string
13
+ historyIndex: number
14
+ savedHistory?: PromptInputV2HistoryEntry
15
+ }
16
+
17
+ export type PromptInputV2InteractionEvent =
18
+ | { type: "input.changed"; value: string; persist?: boolean }
19
+ | { type: "commands.open" }
20
+ | { type: "context.open" }
21
+ | { type: "popover.query"; value: string }
22
+ | { type: "popover.results"; ids: string[] }
23
+ | { type: "popover.active"; id: string }
24
+ | { type: "popover.close" }
25
+ | { type: "popover.select"; item: PromptInputV2Suggestion }
26
+ | { type: "key.down"; key: string; ctrl: boolean; composing: boolean; ids: string[]; empty?: boolean }
27
+ | { type: "mode.shell" }
28
+ | { type: "mode.normal" }
29
+ | { type: "drag.enter" }
30
+ | { type: "drag.leave" }
31
+ | { type: "focus.editor" }
32
+ | { type: "focus.external" }
33
+ | { type: "context.active"; id: string }
34
+
35
+ export type PromptInputV2InteractionCommand =
36
+ | { type: "draft.setText"; value: string }
37
+ | { type: "mention.add"; item: PromptInputV2Suggestion }
38
+ | { type: "popover.filter"; popover: "command" | "context"; query: string }
39
+ | { type: "suggestion.select"; id: string }
40
+ | { type: "focus.editor" }
41
+ | { type: "focus.command-search" }
42
+
43
+ export type PromptInputV2Transition = {
44
+ state: PromptInputV2InteractionState
45
+ commands: PromptInputV2InteractionCommand[]
46
+ handled: boolean
47
+ }
48
+
49
+ export function createPromptInputV2InteractionState(): PromptInputV2InteractionState {
50
+ return {
51
+ mode: "normal",
52
+ popover: { type: "closed" },
53
+ drag: "idle",
54
+ focus: "external",
55
+ historyIndex: -1,
56
+ }
57
+ }
58
+
59
+ export function transitionPromptInputV2(
60
+ state: PromptInputV2InteractionState,
61
+ event: PromptInputV2InteractionEvent,
62
+ persisted: PromptInputV2PersistedState,
63
+ ): PromptInputV2Transition {
64
+ if (event.type === "input.changed") return inputChanged(state, event.value, event.persist !== false, persisted.cursor)
65
+ if (event.type === "commands.open") return openCommands(state, persisted)
66
+ if (event.type === "context.open") return openContext(state, persisted)
67
+ if (event.type === "popover.query") return queryChanged(state, event.value)
68
+ if (event.type === "popover.results") return resultsChanged(state, event.ids)
69
+ if (event.type === "popover.active") return activeChanged(state, event.id)
70
+ if (event.type === "popover.close") return changed({ ...state, popover: { type: "closed" } })
71
+ if (event.type === "popover.select") return suggestionSelected(state, event.item, persisted)
72
+ if (event.type === "key.down") return keyDown(state, event)
73
+ if (event.type === "mode.shell") return changed({ ...state, mode: "shell", popover: { type: "closed" } })
74
+ if (event.type === "mode.normal") return changed({ ...state, mode: "normal" })
75
+ if (event.type === "drag.enter") return changed({ ...state, drag: "active" })
76
+ if (event.type === "drag.leave") return changed({ ...state, drag: "idle" })
77
+ if (event.type === "focus.editor") return changed({ ...state, focus: "editor" })
78
+ if (event.type === "context.active") {
79
+ return changed({ ...state, activeContextID: state.activeContextID === event.id ? undefined : event.id })
80
+ }
81
+ return changed({ ...state, focus: "external" })
82
+ }
83
+
84
+ function inputChanged(
85
+ state: PromptInputV2InteractionState,
86
+ value: string,
87
+ persist: boolean,
88
+ cursor: number | undefined,
89
+ ): PromptInputV2Transition {
90
+ const setText: PromptInputV2InteractionCommand[] = persist ? [{ type: "draft.setText", value }] : []
91
+ if (state.mode === "normal" && value === "!") {
92
+ return changed({ ...state, mode: "shell", popover: { type: "closed" }, focus: "editor" }, [
93
+ { type: "draft.setText", value: "" },
94
+ ])
95
+ }
96
+ const context = value.slice(0, cursor ?? value.length).match(/(?:^|\s)@([^\s@]*)$/)
97
+ if (context) {
98
+ const query = context[1] ?? ""
99
+ return changed({ ...state, popover: { type: "context", query }, focus: "editor" }, [
100
+ ...setText,
101
+ { type: "popover.filter", popover: "context", query },
102
+ ])
103
+ }
104
+
105
+ const command = value.match(/^\/(\S*)$/)
106
+ if (command) {
107
+ const query = command[1] ?? ""
108
+ return changed({ ...state, popover: { type: "command-inline", query }, focus: "editor" }, [
109
+ ...setText,
110
+ { type: "popover.filter", popover: "command", query },
111
+ ])
112
+ }
113
+
114
+ return changed(
115
+ { ...state, popover: state.popover.type === "command-menu" ? state.popover : { type: "closed" }, focus: "editor" },
116
+ setText,
117
+ )
118
+ }
119
+
120
+ function openCommands(
121
+ state: PromptInputV2InteractionState,
122
+ persisted: PromptInputV2PersistedState,
123
+ ): PromptInputV2Transition {
124
+ if (!populated(persisted)) {
125
+ return changed({ ...state, popover: { type: "command-inline", query: "" }, focus: "editor" }, [
126
+ { type: "draft.setText", value: promptText(persisted) + "/" },
127
+ { type: "popover.filter", popover: "command", query: "" },
128
+ { type: "focus.editor" },
129
+ ])
130
+ }
131
+ return changed({ ...state, popover: { type: "command-menu", query: "" }, focus: "command-search" }, [
132
+ { type: "popover.filter", popover: "command", query: "" },
133
+ { type: "focus.command-search" },
134
+ ])
135
+ }
136
+
137
+ function openContext(
138
+ state: PromptInputV2InteractionState,
139
+ persisted: PromptInputV2PersistedState,
140
+ ): PromptInputV2Transition {
141
+ return changed({ ...state, popover: { type: "context", query: "" }, focus: "editor" }, [
142
+ { type: "draft.setText", value: promptText(persisted) + "@" },
143
+ { type: "popover.filter", popover: "context", query: "" },
144
+ { type: "focus.editor" },
145
+ ])
146
+ }
147
+
148
+ function queryChanged(state: PromptInputV2InteractionState, query: string): PromptInputV2Transition {
149
+ if (state.popover.type === "closed") return unchanged(state)
150
+ const popover = state.popover.type === "context" ? "context" : "command"
151
+ return changed({ ...state, popover: { ...state.popover, query, activeID: undefined } }, [
152
+ { type: "popover.filter", popover, query },
153
+ ])
154
+ }
155
+
156
+ function resultsChanged(state: PromptInputV2InteractionState, ids: string[]): PromptInputV2Transition {
157
+ if (state.popover.type === "closed") return unchanged(state)
158
+ const activeID = state.popover.activeID && ids.includes(state.popover.activeID) ? state.popover.activeID : ids[0]
159
+ if (activeID === state.popover.activeID) return unchanged(state)
160
+ return changed({ ...state, popover: { ...state.popover, activeID } })
161
+ }
162
+
163
+ function activeChanged(state: PromptInputV2InteractionState, id: string): PromptInputV2Transition {
164
+ if (state.popover.type === "closed" || state.popover.activeID === id) return unchanged(state)
165
+ return changed({ ...state, popover: { ...state.popover, activeID: id } })
166
+ }
167
+
168
+ function suggestionSelected(
169
+ state: PromptInputV2InteractionState,
170
+ item: PromptInputV2Suggestion,
171
+ persisted: PromptInputV2PersistedState,
172
+ ): PromptInputV2Transition {
173
+ const current = promptText(persisted)
174
+ const commands: PromptInputV2InteractionCommand[] = []
175
+ if (item.kind === "command") {
176
+ commands.push({
177
+ type: "draft.setText",
178
+ value:
179
+ state.popover.type === "command-menu"
180
+ ? current.trim()
181
+ ? `${item.label} ${current.trim()}`
182
+ : `${item.label} `
183
+ : replaceTrigger(current, "/", `${item.label} `),
184
+ })
185
+ } else {
186
+ commands.push({ type: "mention.add", item })
187
+ }
188
+ commands.push({ type: "focus.editor" })
189
+ return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, commands)
190
+ }
191
+
192
+ function keyDown(
193
+ state: PromptInputV2InteractionState,
194
+ event: Extract<PromptInputV2InteractionEvent, { type: "key.down" }>,
195
+ ): PromptInputV2Transition {
196
+ if (event.ctrl && event.key.toLowerCase() === "g") {
197
+ if (state.popover.type === "closed") return unchanged(state)
198
+ return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, [{ type: "focus.editor" }], true)
199
+ }
200
+ if (state.popover.type === "closed") {
201
+ if (state.mode === "shell" && (event.key === "Escape" || (event.key === "Backspace" && event.empty))) {
202
+ return changed({ ...state, mode: "normal" }, [], true)
203
+ }
204
+ return unchanged(state)
205
+ }
206
+ if (event.key === "Escape") {
207
+ return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, [{ type: "focus.editor" }], true)
208
+ }
209
+ if (event.key === "Tab" || (event.key === "Enter" && !event.composing)) {
210
+ if (!state.popover.activeID) return unchanged(state, true)
211
+ return unchanged(state, true, [{ type: "suggestion.select", id: state.popover.activeID }])
212
+ }
213
+ const direction =
214
+ event.key === "ArrowDown" || (event.ctrl && event.key === "n")
215
+ ? 1
216
+ : event.key === "ArrowUp" || (event.ctrl && event.key === "p")
217
+ ? -1
218
+ : 0
219
+ if (!direction || event.ids.length === 0) return unchanged(state)
220
+ const current = state.popover.activeID ? event.ids.indexOf(state.popover.activeID) : -1
221
+ const index =
222
+ current < 0
223
+ ? direction === 1
224
+ ? 0
225
+ : event.ids.length - 1
226
+ : (current + direction + event.ids.length) % event.ids.length
227
+ return changed({ ...state, popover: { ...state.popover, activeID: event.ids[index] } }, [], true)
228
+ }
229
+
230
+ function promptText(persisted: PromptInputV2PersistedState) {
231
+ return persisted.prompt.map((part) => (part.type === "text" ? part.content : "")).join("")
232
+ }
233
+
234
+ function populated(persisted: PromptInputV2PersistedState) {
235
+ return (
236
+ !!promptText(persisted).trim() ||
237
+ persisted.context.items.length > 0 ||
238
+ persisted.prompt.some((part) => part.type === "file" || part.type === "image")
239
+ )
240
+ }
241
+
242
+ function replaceTrigger(value: string, trigger: "@" | "/", replacement: string) {
243
+ const index = trigger === "/" ? value.indexOf(trigger) : value.lastIndexOf(trigger)
244
+ return index < 0 ? replacement : value.slice(0, index) + replacement
245
+ }
246
+
247
+ function changed(
248
+ state: PromptInputV2InteractionState,
249
+ commands: PromptInputV2InteractionCommand[] = [],
250
+ handled = false,
251
+ ): PromptInputV2Transition {
252
+ return { state, commands, handled }
253
+ }
254
+
255
+ function unchanged(
256
+ state: PromptInputV2InteractionState,
257
+ handled = false,
258
+ commands: PromptInputV2InteractionCommand[] = [],
259
+ ): PromptInputV2Transition {
260
+ return { state, commands, handled }
261
+ }
@@ -0,0 +1,221 @@
1
+ // @ts-nocheck
2
+ import { createStore } from "solid-js/store"
3
+ import { PromptInputV2, type PromptInputV2PersistedState, type PromptInputV2Suggestion } from "."
4
+ import { createPromptInputV2Controller } from "./interaction"
5
+ import { createPromptInputV2Store } from "./store"
6
+ import { createEffect } from "solid-js"
7
+
8
+ const agents = [
9
+ { id: "build", label: "Build" },
10
+ { id: "plan", label: "Plan" },
11
+ { id: "review", label: "Review" },
12
+ ]
13
+
14
+ const variants = [
15
+ { id: "default", label: "Default" },
16
+ { id: "fast", label: "Fast" },
17
+ { id: "thinking", label: "Thinking" },
18
+ ]
19
+
20
+ const models = [
21
+ { id: "claude-sonnet", name: "Claude Sonnet", providerID: "anthropic" },
22
+ { id: "gpt-5", name: "GPT-5", providerID: "openai" },
23
+ { id: "gemini-pro", name: "Gemini Pro", providerID: "google" },
24
+ ]
25
+
26
+ const contextSuggestions: PromptInputV2Suggestion[] = [
27
+ {
28
+ id: "file-prompt",
29
+ kind: "file",
30
+ label: "prompt-input-v2.tsx",
31
+ path: "src/components/prompt-input-v2.tsx",
32
+ recent: true,
33
+ mention: {
34
+ type: "file",
35
+ path: "src/components/prompt-input-v2.tsx",
36
+ content: "@src/components/prompt-input-v2.tsx",
37
+ start: 0,
38
+ end: 0,
39
+ },
40
+ },
41
+ {
42
+ id: "file-story",
43
+ kind: "file",
44
+ label: "prompt-input-v2.stories.tsx",
45
+ path: "src/components/prompt-input-v2.stories.tsx",
46
+ mention: {
47
+ type: "file",
48
+ path: "src/components/prompt-input-v2.stories.tsx",
49
+ content: "@src/components/prompt-input-v2.stories.tsx",
50
+ start: 0,
51
+ end: 0,
52
+ },
53
+ },
54
+ {
55
+ id: "agent-review",
56
+ kind: "agent",
57
+ label: "@review",
58
+ description: "Ask the review agent",
59
+ mention: { type: "agent", name: "review", content: "@review", start: 0, end: 0 },
60
+ },
61
+ {
62
+ id: "reference-docs",
63
+ kind: "reference",
64
+ label: "@UI guidelines",
65
+ path: "docs/ui.md",
66
+ description: "Project reference",
67
+ mention: {
68
+ type: "file",
69
+ path: "docs/ui.md",
70
+ content: "@UI guidelines",
71
+ start: 0,
72
+ end: 0,
73
+ mime: "application/x-directory",
74
+ filename: "UI guidelines",
75
+ },
76
+ },
77
+ ]
78
+
79
+ const commandSuggestions: PromptInputV2Suggestion[] = [
80
+ {
81
+ id: "command-fix",
82
+ kind: "command",
83
+ label: "/fix",
84
+ trigger: "fix",
85
+ title: "Fix",
86
+ description: "Fix the current issue",
87
+ keybind: ["Enter"],
88
+ },
89
+ {
90
+ id: "command-review",
91
+ kind: "command",
92
+ label: "/review",
93
+ trigger: "review",
94
+ title: "Review",
95
+ description: "Review pending changes",
96
+ },
97
+ {
98
+ id: "command-test",
99
+ kind: "command",
100
+ label: "/test",
101
+ trigger: "test",
102
+ title: "Test",
103
+ description: "Run relevant tests",
104
+ },
105
+ ]
106
+
107
+ function ControlledPromptInput() {
108
+ // Agent choice is a persisted user/workspace preference in v1, not part of PromptStore.
109
+ const [preferences, setPreferences] = createStore({ agent: "build" })
110
+
111
+ const [runtime, setRuntime] = createStore({
112
+ stopping: false,
113
+ })
114
+
115
+ // This matches the v1 PromptStore and can use the same persistence boundary.
116
+ const state = createStore<PromptInputV2PersistedState>({
117
+ prompt: [
118
+ { type: "text", content: "", start: 0, end: 0 },
119
+ {
120
+ type: "image",
121
+ id: "attachment-1",
122
+ filename: "requirements.md",
123
+ mime: "text/markdown",
124
+ dataUrl: "data:text/markdown;base64,IyBSZXF1aXJlbWVudHM=",
125
+ },
126
+ ],
127
+ cursor: 0,
128
+ model: { providerID: "anthropic", modelID: "claude-sonnet", variant: null },
129
+ context: {
130
+ items: [
131
+ {
132
+ key: "file:src/components/prompt-input-v2.tsx:1:40",
133
+ type: "file",
134
+ path: "src/components/prompt-input-v2.tsx",
135
+ selection: { startLine: 1, startChar: 0, endLine: 40, endChar: 0 },
136
+ comment: "Keep this component context-free",
137
+ },
138
+ ],
139
+ },
140
+ })
141
+ const store = createPromptInputV2Store(state)
142
+
143
+ const controller = createPromptInputV2Controller({
144
+ store: state,
145
+ commands: () => commandSuggestions,
146
+ context: () => contextSuggestions,
147
+ searchContextFiles: (query) => {
148
+ const needle = query.trim().toLowerCase()
149
+ return contextSuggestions.filter(
150
+ (item) => item.kind === "file" && `${item.label} ${item.path ?? ""}`.toLowerCase().includes(needle),
151
+ )
152
+ },
153
+ view: {
154
+ add: {
155
+ onAttach: () => addAttachment("architecture.txt", "text/plain"),
156
+ },
157
+ agent: {
158
+ options: () => agents,
159
+ current: () => preferences.agent,
160
+ onSelect: (agent) => setPreferences("agent", agent),
161
+ },
162
+ model: {
163
+ options: () => models.map((model) => ({ id: model.id, label: model.name, providerID: model.providerID })),
164
+ current: () =>
165
+ models.find(
166
+ (model) => model.id === store.state.model?.modelID && model.providerID === store.state.model?.providerID,
167
+ )?.id ?? "",
168
+ onSelect(id) {
169
+ const model = models.find((item) => item.id === id)
170
+ if (!model) return
171
+ store.setModel({
172
+ providerID: model.providerID,
173
+ modelID: model.id,
174
+ variant: store.state.model?.variant,
175
+ })
176
+ },
177
+ },
178
+ variant: {
179
+ options: () => variants,
180
+ current: () => store.state.model?.variant ?? "default",
181
+ onSelect: (variant) => store.setVariant(variant === "default" ? null : variant),
182
+ },
183
+ submit: {
184
+ stopping: () => runtime.stopping,
185
+ working: () => runtime.stopping,
186
+ onSubmit: () => {
187
+ store.reset()
188
+ setRuntime("stopping", true)
189
+ },
190
+ onStop: () => setRuntime("stopping", false),
191
+ },
192
+ onDrop: (event) => addAttachment(event.dataTransfer?.files[0]?.name ?? "dropped-file.txt", "text/plain"),
193
+ },
194
+ })
195
+
196
+ const addAttachment = (filename: string, mime: string) => {
197
+ store.addAttachment({
198
+ type: "image",
199
+ id: `attachment-${store.state.prompt.filter((part) => part.type === "image").length + 1}`,
200
+ filename,
201
+ mime,
202
+ dataUrl: `data:${mime};base64,`,
203
+ })
204
+ }
205
+
206
+ return (
207
+ <div class="mx-auto flex max-w-[760px] flex-col gap-4 pt-32">
208
+ <PromptInputV2 controller={controller} />
209
+ </div>
210
+ )
211
+ }
212
+
213
+ export default {
214
+ title: "Session UI/PromptInputV2",
215
+ id: "session-ui-prompt-input-v2",
216
+ component: PromptInputV2,
217
+ }
218
+
219
+ export const ControlledComposition = {
220
+ render: () => <ControlledPromptInput />,
221
+ }
@@ -0,0 +1,116 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { createStore } from "solid-js/store"
3
+ import type { PromptInputV2PersistedState } from "./types"
4
+ import { createPromptInputV2Store } from "./store"
5
+
6
+ function createPromptStore() {
7
+ return createPromptInputV2Store(
8
+ createStore<PromptInputV2PersistedState>({
9
+ prompt: [
10
+ { type: "text", content: "old", start: 0, end: 3 },
11
+ {
12
+ type: "image",
13
+ id: "attachment-1",
14
+ filename: "notes.txt",
15
+ mime: "text/plain",
16
+ dataUrl: "data:text/plain;base64,",
17
+ },
18
+ ],
19
+ cursor: 3,
20
+ model: { providerID: "anthropic", modelID: "claude-sonnet", variant: null },
21
+ context: { items: [] },
22
+ }),
23
+ )
24
+ }
25
+
26
+ describe("prompt input v2 store", () => {
27
+ test("accepts an accessor for the backing store", () => {
28
+ const [state, setState] = createStore<PromptInputV2PersistedState>({
29
+ prompt: [{ type: "text", content: "", start: 0, end: 0 }],
30
+ cursor: 0,
31
+ context: { items: [] },
32
+ })
33
+ const prompt = createPromptInputV2Store([() => state, setState])
34
+
35
+ prompt.setText("accessed")
36
+
37
+ expect(prompt.state.prompt).toEqual([{ type: "text", content: "accessed", start: 0, end: 8 }])
38
+ expect(prompt.state.cursor).toBe(8)
39
+ })
40
+
41
+ test("updates prompt text and cursor together while preserving attachments", () => {
42
+ const prompt = createPromptStore()
43
+
44
+ prompt.setText("updated")
45
+
46
+ expect(prompt.state.prompt).toEqual([
47
+ { type: "text", content: "updated", start: 0, end: 7 },
48
+ {
49
+ type: "image",
50
+ id: "attachment-1",
51
+ filename: "notes.txt",
52
+ mime: "text/plain",
53
+ dataUrl: "data:text/plain;base64,",
54
+ },
55
+ ])
56
+ expect(prompt.state.cursor).toBe(7)
57
+ })
58
+
59
+ test("inserts text without flattening structured mentions", () => {
60
+ const [state, setState] = createStore<PromptInputV2PersistedState>({
61
+ prompt: [
62
+ { type: "text", content: "A ", start: 0, end: 2 },
63
+ { type: "file", path: "one", content: "@one", start: 2, end: 6 },
64
+ { type: "text", content: " B", start: 6, end: 8 },
65
+ ],
66
+ cursor: 2,
67
+ context: { items: [] },
68
+ })
69
+ const prompt = createPromptInputV2Store([state, setState])
70
+
71
+ prompt.addText("X\nY")
72
+
73
+ expect(prompt.state.prompt).toEqual([
74
+ { type: "text", content: "A X\nY", start: 0, end: 5 },
75
+ { type: "file", path: "one", content: "@one", start: 5, end: 9 },
76
+ { type: "text", content: " B", start: 9, end: 11 },
77
+ ])
78
+ expect(prompt.state.cursor).toBe(5)
79
+ })
80
+
81
+ test("mutates context, attachments, and model through shared actions", () => {
82
+ const prompt = createPromptStore()
83
+ const context = { key: "file:src/index.ts", type: "file" as const, path: "src/index.ts" }
84
+
85
+ prompt.addContext(context)
86
+ prompt.addContext(context)
87
+ prompt.addMention({ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 0 })
88
+ prompt.removeAttachment("attachment-1")
89
+ prompt.setVariant("thinking")
90
+
91
+ expect(prompt.state.context.items).toEqual([context])
92
+ expect(prompt.state.prompt).toEqual([
93
+ { type: "text", content: "old", start: 0, end: 3 },
94
+ { type: "file", path: "src/app.ts", content: "@src/app.ts", start: 3, end: 14 },
95
+ { type: "text", content: " ", start: 14, end: 15 },
96
+ ])
97
+ expect(prompt.state.model?.variant).toBe("thinking")
98
+
99
+ prompt.removeContext(context.key)
100
+ prompt.setPrompt([{ type: "text", content: "old", start: 0, end: 3 }], 3)
101
+ prompt.setModel(undefined)
102
+
103
+ expect(prompt.state.context.items).toEqual([])
104
+ expect(prompt.state.prompt).toEqual([{ type: "text", content: "old", start: 0, end: 3 }])
105
+ expect(prompt.state.model).toBeUndefined()
106
+ })
107
+
108
+ test("resets the prompt and cursor", () => {
109
+ const prompt = createPromptStore()
110
+
111
+ prompt.reset()
112
+
113
+ expect(prompt.state.prompt).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
114
+ expect(prompt.state.cursor).toBe(0)
115
+ })
116
+ })