@yeshwanthyk/coding-agent 0.2.2

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 (79) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +372 -0
  3. package/package.json +42 -0
  4. package/src/adapters/acp/index.ts +305 -0
  5. package/src/adapters/acp/protocol.ts +191 -0
  6. package/src/adapters/acp/session.ts +289 -0
  7. package/src/adapters/acp/updates.ts +96 -0
  8. package/src/adapters/cli/headless.ts +112 -0
  9. package/src/adapters/cli/validate.ts +50 -0
  10. package/src/adapters/tui/app.tsx +39 -0
  11. package/src/agent-events.ts +671 -0
  12. package/src/args.ts +102 -0
  13. package/src/autocomplete-commands.ts +102 -0
  14. package/src/commands.ts +23 -0
  15. package/src/compact-handler.ts +272 -0
  16. package/src/components/Footer.tsx +49 -0
  17. package/src/components/Header.tsx +218 -0
  18. package/src/components/MessageList.tsx +380 -0
  19. package/src/config.ts +1 -0
  20. package/src/domain/commands/builtin/clear.ts +14 -0
  21. package/src/domain/commands/builtin/compact.ts +96 -0
  22. package/src/domain/commands/builtin/conceal.ts +9 -0
  23. package/src/domain/commands/builtin/diffwrap.ts +9 -0
  24. package/src/domain/commands/builtin/editor.ts +24 -0
  25. package/src/domain/commands/builtin/exit.ts +14 -0
  26. package/src/domain/commands/builtin/followup.ts +24 -0
  27. package/src/domain/commands/builtin/index.ts +29 -0
  28. package/src/domain/commands/builtin/login.ts +118 -0
  29. package/src/domain/commands/builtin/model.ts +66 -0
  30. package/src/domain/commands/builtin/status.ts +32 -0
  31. package/src/domain/commands/builtin/steer.ts +24 -0
  32. package/src/domain/commands/builtin/theme.ts +23 -0
  33. package/src/domain/commands/builtin/thinking.ts +16 -0
  34. package/src/domain/commands/helpers.ts +41 -0
  35. package/src/domain/commands/registry.ts +42 -0
  36. package/src/domain/commands/types.ts +69 -0
  37. package/src/domain/messaging/content.ts +117 -0
  38. package/src/editor.ts +103 -0
  39. package/src/extensibility/schema.ts +1 -0
  40. package/src/extensibility/validation.ts +1 -0
  41. package/src/hooks/index.ts +1 -0
  42. package/src/hooks/useAgentEvents.ts +28 -0
  43. package/src/hooks/useEditorBridge.ts +101 -0
  44. package/src/hooks/useGitStatus.ts +28 -0
  45. package/src/hooks/usePromptQueue.ts +7 -0
  46. package/src/hooks/useSessionController.ts +5 -0
  47. package/src/hooks/useSpinner.ts +28 -0
  48. package/src/hooks/useToastManager.ts +26 -0
  49. package/src/index.ts +188 -0
  50. package/src/keyboard-handler.ts +134 -0
  51. package/src/profiler.ts +40 -0
  52. package/src/runtime/context.tsx +16 -0
  53. package/src/runtime/factory.ts +63 -0
  54. package/src/runtime/git/git-info.ts +25 -0
  55. package/src/runtime/session/session-controller.ts +208 -0
  56. package/src/session-manager.ts +1 -0
  57. package/src/session-picker.tsx +134 -0
  58. package/src/shell-runner.ts +134 -0
  59. package/src/syntax-highlighting.ts +114 -0
  60. package/src/theme-names.ts +37 -0
  61. package/src/tool-ui-contracts.ts +77 -0
  62. package/src/tui-open-rendering.tsx +565 -0
  63. package/src/types.ts +89 -0
  64. package/src/ui/app-shell/TuiApp.tsx +586 -0
  65. package/src/ui/clipboard/osc52.ts +18 -0
  66. package/src/ui/components/modals/ConfirmModal.tsx +52 -0
  67. package/src/ui/components/modals/EditorModal.tsx +39 -0
  68. package/src/ui/components/modals/InputModal.tsx +30 -0
  69. package/src/ui/components/modals/ModalContainer.tsx +67 -0
  70. package/src/ui/components/modals/SelectModal.tsx +48 -0
  71. package/src/ui/components/modals/index.ts +4 -0
  72. package/src/ui/features/composer/Composer.tsx +73 -0
  73. package/src/ui/features/composer/SlashCommandHandler.ts +58 -0
  74. package/src/ui/features/composer/keyboard.ts +3 -0
  75. package/src/ui/features/main-view/MainView.tsx +367 -0
  76. package/src/ui/features/message-pane/MessagePane.tsx +34 -0
  77. package/src/ui/hooks/useModals.ts +74 -0
  78. package/src/ui/state/app-store.ts +67 -0
  79. package/src/utils.ts +14 -0
@@ -0,0 +1,586 @@
1
+ import { ThemeProvider, type ThemeMode } from "@yeshwanthyk/open-tui"
2
+ import { batch, onMount } from "solid-js"
3
+ import { Effect } from "effect"
4
+
5
+ /** Detect system dark/light mode (macOS only, defaults to dark) */
6
+ function detectThemeMode(): ThemeMode {
7
+ try {
8
+ const result = Bun.spawnSync(["defaults", "read", "-g", "AppleInterfaceStyle"])
9
+ return result.stdout.toString().trim().toLowerCase() === "dark" ? "dark" : "light"
10
+ } catch {
11
+ return "dark"
12
+ }
13
+ }
14
+ import { useRuntime } from "../../runtime/context.js"
15
+ import type { LoadedSession } from "../../session-manager.js"
16
+ import { createSessionController } from "@runtime/session/session-controller.js"
17
+ import { createPromptQueue, type PromptDeliveryMode } from "@yeshwanthyk/runtime-effect/session/prompt-queue.js"
18
+ import { appendWithCap } from "@domain/messaging/content.js"
19
+ import type { UIShellMessage, UIMessage } from "../../types.js"
20
+ import type { AppMessage } from "@yeshwanthyk/agent-core"
21
+ import { runShellCommand } from "../../shell-runner.js"
22
+ import { MainView } from "../features/main-view/MainView.js"
23
+ import { createAppStore } from "../state/app-store.js"
24
+ import { useAgentEvents } from "../../hooks/useAgentEvents.js"
25
+ import type { EventHandlerContext, ToolMeta } from "../../agent-events.js"
26
+ import { THINKING_LEVELS, type CommandContext } from "../../commands.js"
27
+ import { slashCommands } from "../../autocomplete-commands.js"
28
+ import { updateAppConfig } from "../../config.js"
29
+ import { handleSlashInput } from "../features/composer/SlashCommandHandler.js"
30
+ import { createHookMessage, createHookUIContext, type HookMessage, type HookSessionContext, type CompletionResult } from "../../hooks/index.js"
31
+ import { completeSimple, type Message } from "@yeshwanthyk/ai"
32
+ import { useModals } from "../hooks/useModals.js"
33
+ import { ModalContainer } from "../components/modals/ModalContainer.js"
34
+
35
+ const SHELL_INJECTION_PREFIX = "[Shell output]" as const
36
+
37
+ export interface TuiAppProps {
38
+ initialSession: LoadedSession | null
39
+ }
40
+
41
+ export const TuiApp = ({ initialSession }: TuiAppProps) => {
42
+ const runtime = useRuntime()
43
+ const {
44
+ agent,
45
+ sessionManager,
46
+ hookRunner,
47
+ toolByName,
48
+ customCommands,
49
+ lsp,
50
+ config,
51
+ codexTransport,
52
+ getApiKey,
53
+ sendRef,
54
+ lspActiveRef,
55
+ cycleModels,
56
+ validationIssues,
57
+ } = runtime
58
+
59
+ const toolMetaByName = new Map<string, ToolMeta>()
60
+ for (const [name, entry] of toolByName.entries()) {
61
+ toolMetaByName.set(name, {
62
+ label: entry.label,
63
+ source: entry.source,
64
+ sourcePath: entry.sourcePath,
65
+ renderCall: entry.renderCall as ToolMeta["renderCall"],
66
+ renderResult: entry.renderResult as ToolMeta["renderResult"],
67
+ })
68
+ }
69
+
70
+ const store = createAppStore({
71
+ initialTheme: config.theme,
72
+ initialModelId: config.modelId,
73
+ initialThinking: config.thinking,
74
+ initialContextWindow: config.model.contextWindow,
75
+ initialProvider: config.provider,
76
+ })
77
+
78
+ const promptQueue = createPromptQueue((counts) => store.queueCounts.set(counts))
79
+ const modals = useModals()
80
+
81
+ const sessionController = createSessionController({
82
+ initialProvider: config.provider,
83
+ initialModel: config.model,
84
+ initialModelId: config.modelId,
85
+ initialThinking: config.thinking,
86
+ agent,
87
+ sessionManager,
88
+ hookRunner,
89
+ toolByName: toolMetaByName,
90
+ setMessages: store.messages.set,
91
+ setContextTokens: store.contextTokens.set,
92
+ setDisplayProvider: store.currentProvider.set,
93
+ setDisplayModelId: store.displayModelId.set,
94
+ setDisplayThinking: store.displayThinking.set,
95
+ setDisplayContextWindow: store.displayContextWindow.set,
96
+ shellInjectionPrefix: SHELL_INJECTION_PREFIX,
97
+ promptQueue,
98
+ })
99
+
100
+ onMount(() => {
101
+ if (initialSession) {
102
+ sessionController.restoreSession(initialSession)
103
+ }
104
+ })
105
+
106
+ const ensureSession = () => sessionController.ensureSession()
107
+
108
+ let cycleIndex = cycleModels.findIndex(
109
+ (entry) => entry.model.id === config.modelId && entry.provider === config.provider,
110
+ )
111
+ if (cycleIndex < 0) cycleIndex = 0
112
+
113
+ const streamingMessageIdRef = { current: null as string | null }
114
+ const retryConfig = { enabled: true, maxRetries: 3, baseDelayMs: 2000 }
115
+ const retryablePattern =
116
+ /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error/i
117
+ const retryState = { attempt: 0, abortController: null as AbortController | null }
118
+
119
+ lspActiveRef.setActive = store.lspActive.set
120
+
121
+ const eventCtx: EventHandlerContext = {
122
+ setMessages: store.messages.set,
123
+ setToolBlocks: store.toolBlocks.set,
124
+ setActivityState: store.activityState.set,
125
+ setIsResponding: store.isResponding.set,
126
+ setContextTokens: store.contextTokens.set,
127
+ setCacheStats: store.cacheStats.set,
128
+ setRetryStatus: store.retryStatus.set,
129
+ setTurnCount: store.turnCount.set,
130
+ promptQueue,
131
+ sessionManager,
132
+ streamingMessageId: streamingMessageIdRef,
133
+ retryConfig,
134
+ retryablePattern,
135
+ retryState,
136
+ agent: agent as EventHandlerContext["agent"],
137
+ hookRunner,
138
+ toolByName: toolMetaByName,
139
+ getContextWindow: () => store.displayContextWindow.value(),
140
+ }
141
+
142
+ useAgentEvents({ agent, context: eventCtx })
143
+
144
+ const handleThemeChange = (name: string) => {
145
+ store.theme.set(name)
146
+ void updateAppConfig({ configDir: config.configDir, configPath: config.configPath }, { theme: name })
147
+ }
148
+
149
+ const exitHandlerRef = { current: () => process.exit(0) }
150
+ const editorOpenRef = { current: async () => {} }
151
+ const setEditorTextRef = { current: (_text: string) => {} }
152
+ const getEditorTextRef = { current: () => "" }
153
+ const showToastRef = { current: (_title: string, _message: string, _variant?: "info" | "warning" | "success" | "error") => {} }
154
+ // Flag to skip editor clear when hooks populate the editor via setEditorText
155
+ const skipNextEditorClearRef = { current: false }
156
+
157
+ const handleBeforeExit = async () => {
158
+ // Emit shutdown hook before exiting
159
+ await hookRunner.emit({ type: "session.shutdown", sessionId: sessionManager.sessionId })
160
+ }
161
+
162
+ const submitPrompt = async (text: string, mode: PromptDeliveryMode = "followUp") => {
163
+ const trimmed = text.trim()
164
+ if (!trimmed) return
165
+
166
+ ensureSession()
167
+
168
+ let beforeStartResult: Awaited<ReturnType<typeof hookRunner.emitBeforeAgentStart>> | undefined
169
+ try {
170
+ beforeStartResult = await hookRunner.emitBeforeAgentStart(trimmed)
171
+ const hookMsg = beforeStartResult?.message ? createHookMessage(beforeStartResult.message) : null
172
+ if (hookMsg?.display) {
173
+ const uiMsg: UIMessage = {
174
+ id: crypto.randomUUID(),
175
+ role: "assistant",
176
+ content:
177
+ typeof hookMsg.content === "string"
178
+ ? hookMsg.content
179
+ : hookMsg.content.map((p) => (p.type === "text" ? p.text : "[image]")).join(""),
180
+ timestamp: hookMsg.timestamp,
181
+ }
182
+ store.messages.set((prev) => appendWithCap(prev, uiMsg))
183
+ }
184
+ } catch (err) {
185
+ store.messages.set((prev) =>
186
+ appendWithCap(prev, {
187
+ id: crypto.randomUUID(),
188
+ role: "assistant",
189
+ content: `Hook error: ${err instanceof Error ? err.message : String(err)}`,
190
+ timestamp: Date.now(),
191
+ }),
192
+ )
193
+ }
194
+
195
+ promptQueue.push({ text: trimmed, mode })
196
+ batch(() => {
197
+ store.toolBlocks.set([])
198
+ store.isResponding.set(true)
199
+ store.activityState.set("thinking")
200
+ })
201
+ try {
202
+ await Effect.runPromise(
203
+ runtime.sessionOrchestrator.submitPrompt(trimmed, { mode, beforeStartResult }),
204
+ )
205
+ } catch (err) {
206
+ batch(() => {
207
+ store.messages.set((prev) =>
208
+ appendWithCap(prev, {
209
+ id: crypto.randomUUID(),
210
+ role: "assistant",
211
+ content: `Error: ${err instanceof Error ? err.message : String(err)}`,
212
+ }),
213
+ )
214
+ store.isResponding.set(false)
215
+ store.activityState.set("idle")
216
+ })
217
+ }
218
+ }
219
+
220
+ const steerHelper = async (text: string) => {
221
+ const trimmed = text.trim()
222
+ if (!trimmed) return
223
+ if (store.isResponding.value()) {
224
+ await sessionController.steer(trimmed)
225
+ return
226
+ }
227
+ await submitPrompt(trimmed, "steer")
228
+ }
229
+
230
+ const followUpHelper = async (text: string) => {
231
+ const trimmed = text.trim()
232
+ if (!trimmed) return
233
+ if (store.isResponding.value()) {
234
+ await sessionController.followUp(trimmed)
235
+ return
236
+ }
237
+ await submitPrompt(trimmed, "followUp")
238
+ }
239
+
240
+ const sendUserMessageHelper = async (text: string, options?: { deliverAs?: PromptDeliveryMode }) => {
241
+ const mode: PromptDeliveryMode = options?.deliverAs ?? "followUp"
242
+ if (mode === "steer") {
243
+ await steerHelper(text)
244
+ return
245
+ }
246
+ await followUpHelper(text)
247
+ }
248
+
249
+ const cmdCtx: CommandContext = {
250
+ agent,
251
+ sessionManager,
252
+ configDir: config.configDir,
253
+ configPath: config.configPath,
254
+ cwd: process.cwd(),
255
+ editor: config.editor,
256
+ codexTransport,
257
+ getApiKey,
258
+ get currentProvider() {
259
+ return sessionController.currentProvider()
260
+ },
261
+ get currentModelId() {
262
+ return sessionController.currentModelId()
263
+ },
264
+ get currentThinking() {
265
+ return sessionController.currentThinking()
266
+ },
267
+ setCurrentProvider: (p) => sessionController.setCurrentProvider(p),
268
+ setCurrentModelId: (id) => sessionController.setCurrentModelId(id),
269
+ setCurrentThinking: (t) => sessionController.setCurrentThinking(t),
270
+ isResponding: store.isResponding.value,
271
+ setIsResponding: store.isResponding.set,
272
+ setActivityState: store.activityState.set,
273
+ setMessages: store.messages.set,
274
+ setToolBlocks: store.toolBlocks.set,
275
+ setContextTokens: store.contextTokens.set,
276
+ setCacheStats: store.cacheStats.set,
277
+ setDisplayModelId: store.displayModelId.set,
278
+ setDisplayThinking: store.displayThinking.set,
279
+ setDisplayContextWindow: store.displayContextWindow.set,
280
+ setDiffWrapMode: store.diffWrapMode.set,
281
+ setConcealMarkdown: store.concealMarkdown.set,
282
+ setTheme: handleThemeChange,
283
+ openEditor: () => editorOpenRef.current(),
284
+ onExit: () => exitHandlerRef.current(),
285
+ hookRunner,
286
+ submitPrompt: (text, options) => submitPrompt(text, options?.mode ?? "followUp"),
287
+ steer: (text) => steerHelper(text),
288
+ followUp: (text) => followUpHelper(text),
289
+ sendUserMessage: (text, options) => sendUserMessageHelper(text, options),
290
+ }
291
+
292
+ const builtInCommandNames = new Set(slashCommands.map((c) => c.name))
293
+
294
+ const enqueueWhileResponding = (text: string, mode: PromptDeliveryMode) => {
295
+ void sendUserMessageHelper(text, { deliverAs: mode }).catch((err) => {
296
+ store.messages.set((prev) =>
297
+ appendWithCap(prev, {
298
+ id: crypto.randomUUID(),
299
+ role: "assistant",
300
+ content: `Error: ${err instanceof Error ? err.message : String(err)}`,
301
+ }),
302
+ )
303
+ })
304
+ }
305
+
306
+ const handleSubmit = async (text: string, editorClearFn?: () => void) => {
307
+ if (!text.trim()) return
308
+
309
+ if (text.startsWith("!")) {
310
+ const shouldInject = text.startsWith("!!")
311
+ const command = text.slice(shouldInject ? 2 : 1).trim()
312
+ if (!command) return
313
+ editorClearFn?.()
314
+ ensureSession()
315
+
316
+ const shellMsgId = crypto.randomUUID()
317
+ const pendingMsg: UIShellMessage = {
318
+ id: shellMsgId,
319
+ role: "shell",
320
+ command,
321
+ output: "",
322
+ exitCode: null,
323
+ truncated: false,
324
+ timestamp: Date.now(),
325
+ }
326
+ store.messages.set((prev) => appendWithCap(prev, pendingMsg))
327
+
328
+ const result = await runShellCommand(command, { timeout: 30000 })
329
+ const finalMsg: UIShellMessage = {
330
+ id: shellMsgId,
331
+ role: "shell",
332
+ command,
333
+ output: result.output,
334
+ exitCode: result.exitCode,
335
+ truncated: result.truncated,
336
+ tempFilePath: result.tempFilePath,
337
+ timestamp: Date.now(),
338
+ }
339
+ store.messages.set((prev) => prev.map((m) => (m.id === shellMsgId ? finalMsg : m)))
340
+
341
+ sessionManager.appendMessage({
342
+ role: "shell",
343
+ command,
344
+ output: result.output,
345
+ exitCode: result.exitCode,
346
+ truncated: result.truncated,
347
+ tempFilePath: result.tempFilePath,
348
+ timestamp: Date.now(),
349
+ } as unknown as AppMessage)
350
+
351
+ if (shouldInject) {
352
+ const injectionLines = [`${SHELL_INJECTION_PREFIX}`, `$ ${command}`, result.output]
353
+ if (result.exitCode !== null && result.exitCode !== 0) injectionLines.push(`[exit ${result.exitCode}]`)
354
+ if (result.truncated && result.tempFilePath) injectionLines.push(`[truncated, full output: ${result.tempFilePath}]`)
355
+ const injectedText = injectionLines.filter((line) => line.length > 0).join("\n")
356
+ const injectionMessage: AppMessage = {
357
+ role: "user",
358
+ content: [{ type: "text", text: injectedText }],
359
+ timestamp: Date.now(),
360
+ }
361
+ agent.appendMessage(injectionMessage)
362
+ sessionManager.appendMessage(injectionMessage)
363
+ }
364
+ return
365
+ }
366
+
367
+ if (text.startsWith("/")) {
368
+ const trimmed = text.trim()
369
+ const handled = await handleSlashInput(trimmed, {
370
+ commandContext: cmdCtx,
371
+ customCommands,
372
+ builtInCommandNames,
373
+ onExpand: async (expanded) => handleSubmit(expanded),
374
+ })
375
+ if (handled) {
376
+ // Skip clear if hook populated editor via setEditorText
377
+ if (!skipNextEditorClearRef.current) {
378
+ editorClearFn?.()
379
+ }
380
+ skipNextEditorClearRef.current = false
381
+ return
382
+ }
383
+ }
384
+
385
+ if (store.isResponding.value()) {
386
+ enqueueWhileResponding(text, "followUp")
387
+ editorClearFn?.()
388
+ return
389
+ }
390
+
391
+ editorClearFn?.()
392
+ await submitPrompt(text, "followUp")
393
+ }
394
+
395
+ // Initialize hook runner with full context
396
+ const hookUIContext = createHookUIContext({
397
+ setEditorText: (text) => {
398
+ skipNextEditorClearRef.current = true
399
+ setEditorTextRef.current(text)
400
+ },
401
+ getEditorText: () => getEditorTextRef.current(),
402
+ showSelect: modals.showSelect,
403
+ showInput: modals.showInput,
404
+ showConfirm: modals.showConfirm,
405
+ showEditor: modals.showEditor,
406
+ showNotify: (message, type = "info") => showToastRef.current(type, message, type)
407
+ })
408
+
409
+ const hookSessionContext: HookSessionContext = {
410
+ summarize: async () => {
411
+ // Trigger compaction through the /compact command flow
412
+ await handleSlashInput("/compact", {
413
+ commandContext: cmdCtx,
414
+ customCommands,
415
+ builtInCommandNames,
416
+ onExpand: async (expanded) => handleSubmit(expanded),
417
+ })
418
+ },
419
+ toast: (title, message, variant = "info") => showToastRef.current(title, message, variant),
420
+ getTokenUsage: () => hookRunner["tokenUsage"],
421
+ getContextLimit: () => hookRunner["contextLimit"],
422
+ newSession: async (_opts) => {
423
+ // Clear current session and start fresh
424
+ store.messages.set([])
425
+ store.toolBlocks.set([])
426
+ store.contextTokens.set(0)
427
+ agent.reset()
428
+ void hookRunner.emit({ type: "session.clear", sessionId: null })
429
+ // Start a new session
430
+ sessionManager.startSession(
431
+ sessionController.currentProvider(),
432
+ sessionController.currentModelId(),
433
+ sessionController.currentThinking(),
434
+ )
435
+ return { cancelled: false, sessionId: sessionManager.sessionId ?? undefined }
436
+ },
437
+ getApiKey: async (model) => getApiKey(model.provider),
438
+ complete: async (systemPrompt, userText) => {
439
+ const model = agent.state.model
440
+ const apiKey = getApiKey(model.provider)
441
+ if (!apiKey) {
442
+ return { text: "", stopReason: "error" as const }
443
+ }
444
+ try {
445
+ const userMessage: Message = {
446
+ role: "user",
447
+ content: [{ type: "text", text: userText }],
448
+ timestamp: Date.now(),
449
+ }
450
+ const result = await completeSimple(model, { systemPrompt, messages: [userMessage] }, { apiKey })
451
+ const text = result.content
452
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
453
+ .map((c) => c.text)
454
+ .join("\n")
455
+ // Map stopReason: stop -> end, length -> max_tokens, toolUse -> tool_use
456
+ const stopMap: Record<string, CompletionResult["stopReason"]> = {
457
+ stop: "end", length: "max_tokens", toolUse: "tool_use", error: "error", aborted: "aborted"
458
+ }
459
+ return { text, stopReason: stopMap[result.stopReason] ?? "end" }
460
+ } catch (err) {
461
+ return { text: err instanceof Error ? err.message : String(err), stopReason: "error" as const }
462
+ }
463
+ },
464
+ }
465
+
466
+ const sendMessageHandler = <T = unknown>(
467
+ message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
468
+ triggerTurn?: boolean,
469
+ ) => {
470
+ const hookMessage = createHookMessage(message)
471
+ // Add to UI messages if display is true
472
+ if (hookMessage.display) {
473
+ const uiMsg: UIMessage = {
474
+ id: crypto.randomUUID(),
475
+ role: "assistant", // Render hook messages as assistant for now
476
+ content: typeof hookMessage.content === "string"
477
+ ? hookMessage.content
478
+ : hookMessage.content.map(p => p.type === "text" ? p.text : "[image]").join(""),
479
+ timestamp: hookMessage.timestamp,
480
+ }
481
+ store.messages.set((prev) => appendWithCap(prev, uiMsg))
482
+ }
483
+ // Persist hook message to session
484
+ sessionManager.appendMessage(hookMessage as unknown as AppMessage)
485
+ // Optionally trigger a new turn
486
+ if (triggerTurn) {
487
+ void handleSubmit(typeof hookMessage.content === "string" ? hookMessage.content : "")
488
+ }
489
+ }
490
+
491
+ hookRunner.initialize({
492
+ sendHandler: (text) => void handleSubmit(text),
493
+ sendMessageHandler,
494
+ sendUserMessageHandler: (text, options) => sendUserMessageHelper(text, options),
495
+ steerHandler: (text) => steerHelper(text),
496
+ followUpHandler: (text) => followUpHelper(text),
497
+ isIdleHandler: () => !store.isResponding.value(),
498
+ appendEntryHandler: (customType, data) => sessionManager.appendEntry(customType, data),
499
+ getSessionId: () => sessionManager.sessionId,
500
+ getModel: () => agent.state.model,
501
+ uiContext: hookUIContext,
502
+ sessionContext: hookSessionContext,
503
+ hasUI: true,
504
+ })
505
+
506
+ sendRef.current = (text) => void handleSubmit(text)
507
+
508
+ const handleAbort = (): string | null => {
509
+ if (retryState.abortController) {
510
+ retryState.abortController.abort()
511
+ retryState.abortController = null
512
+ retryState.attempt = 0
513
+ store.retryStatus.set(null)
514
+ }
515
+ agent.abort()
516
+ agent.clearMessageQueue()
517
+ const restore = promptQueue.drainToScript()
518
+ Effect.runFork(Effect.catchAll(runtime.sessionOrchestrator.drainToScript, () => Effect.succeed(null)))
519
+ batch(() => {
520
+ store.isResponding.set(false)
521
+ store.activityState.set("idle")
522
+ })
523
+ return restore
524
+ }
525
+
526
+ const cycleModel = () => {
527
+ if (cycleModels.length <= 1) return
528
+ if (store.isResponding.value()) return
529
+ cycleIndex = (cycleIndex + 1) % cycleModels.length
530
+ const entry = cycleModels[cycleIndex]!
531
+ sessionController.setCurrentProvider(entry.provider)
532
+ sessionController.setCurrentModelId(entry.model.id)
533
+ agent.setModel(entry.model)
534
+ store.displayModelId.set(entry.model.id)
535
+ store.displayContextWindow.set(entry.model.contextWindow)
536
+ }
537
+
538
+ const cycleThinking = () => {
539
+ const current = sessionController.currentThinking()
540
+ const next = THINKING_LEVELS[(THINKING_LEVELS.indexOf(current) + 1) % THINKING_LEVELS.length]!
541
+ sessionController.setCurrentThinking(next)
542
+ agent.setThinkingLevel(next)
543
+ store.displayThinking.set(next)
544
+ }
545
+
546
+ const themeMode = detectThemeMode()
547
+
548
+ return (
549
+ <ThemeProvider mode={themeMode} themeName={store.theme.value()} onThemeChange={handleThemeChange}>
550
+ <MainView
551
+ validationIssues={validationIssues}
552
+ messages={store.messages.value()}
553
+ toolBlocks={store.toolBlocks.value()}
554
+ isResponding={store.isResponding.value()}
555
+ activityState={store.activityState.value()}
556
+ thinkingVisible={store.thinkingVisible.value()}
557
+ modelId={store.displayModelId.value()}
558
+ thinking={store.displayThinking.value()}
559
+ provider={store.currentProvider.value()}
560
+ contextTokens={store.contextTokens.value()}
561
+ contextWindow={store.displayContextWindow.value()}
562
+ queueCounts={store.queueCounts.value()}
563
+ retryStatus={store.retryStatus.value()}
564
+ turnCount={store.turnCount.value()}
565
+ lspActive={store.lspActive.value()}
566
+ diffWrapMode={store.diffWrapMode.value()}
567
+ concealMarkdown={store.concealMarkdown.value()}
568
+ customCommands={customCommands}
569
+ onSubmit={handleSubmit}
570
+ onAbort={handleAbort}
571
+ onToggleThinking={() => store.thinkingVisible.set((v) => !v)}
572
+ onCycleModel={cycleModel}
573
+ onCycleThinking={cycleThinking}
574
+ exitHandlerRef={exitHandlerRef}
575
+ editorOpenRef={editorOpenRef}
576
+ setEditorTextRef={setEditorTextRef}
577
+ getEditorTextRef={getEditorTextRef}
578
+ showToastRef={showToastRef}
579
+ onBeforeExit={handleBeforeExit}
580
+ editor={config.editor}
581
+ lsp={lsp}
582
+ />
583
+ <ModalContainer modalState={modals.modalState()} onClose={modals.closeModal} />
584
+ </ThemeProvider>
585
+ )
586
+ }
@@ -0,0 +1,18 @@
1
+ import { spawnSync } from "child_process"
2
+
3
+ export const copyToClipboard = (text: string): void => {
4
+ const base64 = Buffer.from(text).toString("base64")
5
+ if (process.env["TMUX"]) {
6
+ process.stdout.write(`\x1bPtmux;\x1b\x1b]52;c;${base64}\x07\x1b\\`)
7
+ } else {
8
+ process.stdout.write(`\x1b]52;c;${base64}\x07`)
9
+ }
10
+
11
+ if (process.platform === "darwin") {
12
+ try {
13
+ spawnSync("pbcopy", { input: text, encoding: "utf-8" })
14
+ } catch {
15
+ // Ignore clipboard failures on non-macOS systems
16
+ }
17
+ }
18
+ }
@@ -0,0 +1,52 @@
1
+ import { createSignal } from "solid-js"
2
+ import { useKeyboard } from "@opentui/solid"
3
+ import { Dialog, useTheme } from "@yeshwanthyk/open-tui"
4
+ import type { JSX } from "solid-js"
5
+
6
+ export interface ConfirmModalProps {
7
+ title: string
8
+ message: string
9
+ onConfirm: (confirmed: boolean) => void
10
+ }
11
+
12
+ export function ConfirmModal(props: ConfirmModalProps): JSX.Element {
13
+ const { theme } = useTheme()
14
+ const [selected, setSelected] = createSignal<"yes" | "no">("no")
15
+
16
+ useKeyboard((e: { name: string }) => {
17
+ if (e.name === "left" || e.name === "h") {
18
+ setSelected("yes")
19
+ } else if (e.name === "right" || e.name === "l") {
20
+ setSelected("no")
21
+ } else if (e.name === "y") {
22
+ props.onConfirm(true)
23
+ } else if (e.name === "n" || e.name === "escape") {
24
+ props.onConfirm(false)
25
+ } else if (e.name === "return") {
26
+ props.onConfirm(selected() === "yes")
27
+ }
28
+ })
29
+
30
+ return (
31
+ <Dialog open={true} title={props.title} closeOnOverlayClick={false}>
32
+ <text fg={theme.text}>{props.message}</text>
33
+ <box height={1} />
34
+ <box flexDirection="row" gap={2}>
35
+ <text
36
+ fg={selected() === "yes" ? theme.selectionFg : theme.text}
37
+ bg={selected() === "yes" ? theme.selectionBg : undefined}
38
+ >
39
+ {" [Y]es "}
40
+ </text>
41
+ <text
42
+ fg={selected() === "no" ? theme.selectionFg : theme.text}
43
+ bg={selected() === "no" ? theme.selectionBg : undefined}
44
+ >
45
+ {" [N]o "}
46
+ </text>
47
+ </box>
48
+ <box height={1} />
49
+ <text fg={theme.textMuted}>←/→ or y/n to select • Enter to confirm • Esc to cancel</text>
50
+ </Dialog>
51
+ )
52
+ }
@@ -0,0 +1,39 @@
1
+ import { useKeyboard } from "@opentui/solid"
2
+ import { Dialog, Editor, useTheme, type EditorRef } from "@yeshwanthyk/open-tui"
3
+ import type { JSX } from "solid-js"
4
+
5
+ export interface EditorModalProps {
6
+ title: string
7
+ initialText?: string
8
+ onSubmit: (value: string | undefined) => void
9
+ }
10
+
11
+ export function EditorModal(props: EditorModalProps): JSX.Element {
12
+ const { theme } = useTheme()
13
+ let editorRef: EditorRef | undefined
14
+
15
+ useKeyboard((e: { name: string; ctrl?: boolean; meta?: boolean }) => {
16
+ if (e.name === "escape") {
17
+ props.onSubmit(undefined)
18
+ } else if (e.name === "s" && (e.ctrl || e.meta)) {
19
+ const text = editorRef?.getText()
20
+ props.onSubmit(text)
21
+ }
22
+ })
23
+
24
+ return (
25
+ <Dialog open={true} title={props.title} closeOnOverlayClick={false}>
26
+ <box height="80%" minHeight={10}>
27
+ <Editor
28
+ initialValue={props.initialText}
29
+ focused={true}
30
+ minHeight={10}
31
+ maxHeight={30}
32
+ ref={(ref) => { editorRef = ref }}
33
+ />
34
+ </box>
35
+ <box height={1} />
36
+ <text fg={theme.textMuted}>Ctrl+S to save • Esc to cancel</text>
37
+ </Dialog>
38
+ )
39
+ }