@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.
- package/CHANGELOG.md +40 -0
- package/README.md +372 -0
- package/package.json +42 -0
- package/src/adapters/acp/index.ts +305 -0
- package/src/adapters/acp/protocol.ts +191 -0
- package/src/adapters/acp/session.ts +289 -0
- package/src/adapters/acp/updates.ts +96 -0
- package/src/adapters/cli/headless.ts +112 -0
- package/src/adapters/cli/validate.ts +50 -0
- package/src/adapters/tui/app.tsx +39 -0
- package/src/agent-events.ts +671 -0
- package/src/args.ts +102 -0
- package/src/autocomplete-commands.ts +102 -0
- package/src/commands.ts +23 -0
- package/src/compact-handler.ts +272 -0
- package/src/components/Footer.tsx +49 -0
- package/src/components/Header.tsx +218 -0
- package/src/components/MessageList.tsx +380 -0
- package/src/config.ts +1 -0
- package/src/domain/commands/builtin/clear.ts +14 -0
- package/src/domain/commands/builtin/compact.ts +96 -0
- package/src/domain/commands/builtin/conceal.ts +9 -0
- package/src/domain/commands/builtin/diffwrap.ts +9 -0
- package/src/domain/commands/builtin/editor.ts +24 -0
- package/src/domain/commands/builtin/exit.ts +14 -0
- package/src/domain/commands/builtin/followup.ts +24 -0
- package/src/domain/commands/builtin/index.ts +29 -0
- package/src/domain/commands/builtin/login.ts +118 -0
- package/src/domain/commands/builtin/model.ts +66 -0
- package/src/domain/commands/builtin/status.ts +32 -0
- package/src/domain/commands/builtin/steer.ts +24 -0
- package/src/domain/commands/builtin/theme.ts +23 -0
- package/src/domain/commands/builtin/thinking.ts +16 -0
- package/src/domain/commands/helpers.ts +41 -0
- package/src/domain/commands/registry.ts +42 -0
- package/src/domain/commands/types.ts +69 -0
- package/src/domain/messaging/content.ts +117 -0
- package/src/editor.ts +103 -0
- package/src/extensibility/schema.ts +1 -0
- package/src/extensibility/validation.ts +1 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useAgentEvents.ts +28 -0
- package/src/hooks/useEditorBridge.ts +101 -0
- package/src/hooks/useGitStatus.ts +28 -0
- package/src/hooks/usePromptQueue.ts +7 -0
- package/src/hooks/useSessionController.ts +5 -0
- package/src/hooks/useSpinner.ts +28 -0
- package/src/hooks/useToastManager.ts +26 -0
- package/src/index.ts +188 -0
- package/src/keyboard-handler.ts +134 -0
- package/src/profiler.ts +40 -0
- package/src/runtime/context.tsx +16 -0
- package/src/runtime/factory.ts +63 -0
- package/src/runtime/git/git-info.ts +25 -0
- package/src/runtime/session/session-controller.ts +208 -0
- package/src/session-manager.ts +1 -0
- package/src/session-picker.tsx +134 -0
- package/src/shell-runner.ts +134 -0
- package/src/syntax-highlighting.ts +114 -0
- package/src/theme-names.ts +37 -0
- package/src/tool-ui-contracts.ts +77 -0
- package/src/tui-open-rendering.tsx +565 -0
- package/src/types.ts +89 -0
- package/src/ui/app-shell/TuiApp.tsx +586 -0
- package/src/ui/clipboard/osc52.ts +18 -0
- package/src/ui/components/modals/ConfirmModal.tsx +52 -0
- package/src/ui/components/modals/EditorModal.tsx +39 -0
- package/src/ui/components/modals/InputModal.tsx +30 -0
- package/src/ui/components/modals/ModalContainer.tsx +67 -0
- package/src/ui/components/modals/SelectModal.tsx +48 -0
- package/src/ui/components/modals/index.ts +4 -0
- package/src/ui/features/composer/Composer.tsx +73 -0
- package/src/ui/features/composer/SlashCommandHandler.ts +58 -0
- package/src/ui/features/composer/keyboard.ts +3 -0
- package/src/ui/features/main-view/MainView.tsx +367 -0
- package/src/ui/features/message-pane/MessagePane.tsx +34 -0
- package/src/ui/hooks/useModals.ts +74 -0
- package/src/ui/state/app-store.ts +67 -0
- 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
|
+
}
|