ralph-cli-sandboxed 0.4.1 → 0.4.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 (69) hide show
  1. package/README.md +30 -0
  2. package/dist/commands/action.js +9 -9
  3. package/dist/commands/chat.js +13 -12
  4. package/dist/commands/config.js +2 -1
  5. package/dist/commands/daemon.js +4 -3
  6. package/dist/commands/docker.js +102 -66
  7. package/dist/commands/fix-config.js +2 -1
  8. package/dist/commands/fix-prd.js +2 -2
  9. package/dist/commands/init.js +78 -17
  10. package/dist/commands/listen.js +3 -1
  11. package/dist/commands/notify.js +1 -1
  12. package/dist/commands/once.js +17 -9
  13. package/dist/commands/prd.js +4 -1
  14. package/dist/commands/run.js +40 -25
  15. package/dist/commands/slack.js +2 -2
  16. package/dist/config/responder-presets.json +69 -0
  17. package/dist/index.js +1 -1
  18. package/dist/providers/discord.d.ts +28 -0
  19. package/dist/providers/discord.js +227 -14
  20. package/dist/providers/slack.d.ts +41 -1
  21. package/dist/providers/slack.js +389 -8
  22. package/dist/providers/telegram.d.ts +30 -0
  23. package/dist/providers/telegram.js +185 -5
  24. package/dist/responders/claude-code-responder.d.ts +48 -0
  25. package/dist/responders/claude-code-responder.js +203 -0
  26. package/dist/responders/cli-responder.d.ts +62 -0
  27. package/dist/responders/cli-responder.js +298 -0
  28. package/dist/responders/llm-responder.d.ts +135 -0
  29. package/dist/responders/llm-responder.js +582 -0
  30. package/dist/templates/macos-scripts.js +2 -4
  31. package/dist/templates/prompts.js +4 -2
  32. package/dist/tui/ConfigEditor.js +19 -5
  33. package/dist/tui/components/ArrayEditor.js +1 -1
  34. package/dist/tui/components/EditorPanel.js +10 -6
  35. package/dist/tui/components/HelpPanel.d.ts +1 -1
  36. package/dist/tui/components/HelpPanel.js +1 -1
  37. package/dist/tui/components/JsonSnippetEditor.js +8 -5
  38. package/dist/tui/components/KeyValueEditor.js +54 -9
  39. package/dist/tui/components/LLMProvidersEditor.d.ts +22 -0
  40. package/dist/tui/components/LLMProvidersEditor.js +357 -0
  41. package/dist/tui/components/ObjectEditor.js +1 -1
  42. package/dist/tui/components/Preview.js +1 -1
  43. package/dist/tui/components/RespondersEditor.d.ts +22 -0
  44. package/dist/tui/components/RespondersEditor.js +437 -0
  45. package/dist/tui/components/SectionNav.js +27 -3
  46. package/dist/utils/chat-client.d.ts +4 -0
  47. package/dist/utils/chat-client.js +12 -5
  48. package/dist/utils/config.d.ts +84 -0
  49. package/dist/utils/config.js +78 -1
  50. package/dist/utils/daemon-client.d.ts +21 -0
  51. package/dist/utils/daemon-client.js +28 -1
  52. package/dist/utils/llm-client.d.ts +82 -0
  53. package/dist/utils/llm-client.js +185 -0
  54. package/dist/utils/message-queue.js +6 -6
  55. package/dist/utils/notification.d.ts +6 -1
  56. package/dist/utils/notification.js +103 -2
  57. package/dist/utils/prd-validator.js +60 -19
  58. package/dist/utils/prompt.js +22 -12
  59. package/dist/utils/responder-logger.d.ts +47 -0
  60. package/dist/utils/responder-logger.js +129 -0
  61. package/dist/utils/responder-presets.d.ts +92 -0
  62. package/dist/utils/responder-presets.js +156 -0
  63. package/dist/utils/responder.d.ts +88 -0
  64. package/dist/utils/responder.js +207 -0
  65. package/dist/utils/stream-json.js +6 -6
  66. package/docs/CHAT-RESPONDERS.md +785 -0
  67. package/docs/DEVELOPMENT.md +25 -0
  68. package/docs/chat-architecture.md +251 -0
  69. package/package.json +11 -1
@@ -0,0 +1,22 @@
1
+ import React from "react";
2
+ import type { RespondersConfig } from "../../utils/config.js";
3
+ export interface RespondersEditorProps {
4
+ /** The label to display for this field */
5
+ label: string;
6
+ /** The current responders config */
7
+ responders: RespondersConfig;
8
+ /** Called when the user confirms the edit */
9
+ onConfirm: (newResponders: RespondersConfig) => void;
10
+ /** Called when the user cancels the edit (Esc) */
11
+ onCancel: () => void;
12
+ /** Whether this editor has focus */
13
+ isFocused?: boolean;
14
+ /** Maximum height for the list (for scrolling) */
15
+ maxHeight?: number;
16
+ }
17
+ /**
18
+ * RespondersEditor component for editing chat responder configurations.
19
+ * Provides a user-friendly interface for adding, editing, and removing responders.
20
+ */
21
+ export declare function RespondersEditor({ label, responders, onConfirm, onCancel, isFocused, maxHeight, }: RespondersEditorProps): React.ReactElement;
22
+ export default RespondersEditor;
@@ -0,0 +1,437 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React, { useState, useCallback, useMemo } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import TextInput from "ink-text-input";
5
+ /**
6
+ * Responder type options for dropdown.
7
+ */
8
+ const RESPONDER_TYPES = ["llm", "claude-code", "cli"];
9
+ /**
10
+ * Type descriptions for display.
11
+ */
12
+ const TYPE_DESCRIPTIONS = {
13
+ llm: "LLM provider",
14
+ "claude-code": "Claude Code agent",
15
+ cli: "CLI command",
16
+ };
17
+ /**
18
+ * Default timeouts by responder type.
19
+ */
20
+ const DEFAULT_TIMEOUTS = {
21
+ llm: 60000,
22
+ "claude-code": 300000,
23
+ cli: 60000,
24
+ };
25
+ /**
26
+ * Suggested responder names/presets.
27
+ */
28
+ const SUGGESTED_NAMES = ["default", "qa", "reviewer", "code", "lint"];
29
+ /**
30
+ * RespondersEditor component for editing chat responder configurations.
31
+ * Provides a user-friendly interface for adding, editing, and removing responders.
32
+ */
33
+ export function RespondersEditor({ label, responders, onConfirm, onCancel, isFocused = true, maxHeight = 15, }) {
34
+ const [editResponders, setEditResponders] = useState({ ...responders });
35
+ const [highlightedIndex, setHighlightedIndex] = useState(0);
36
+ const [mode, setMode] = useState("list");
37
+ const [editText, setEditText] = useState("");
38
+ const [editingResponder, setEditingResponder] = useState(null);
39
+ const [typeIndex, setTypeIndex] = useState(0);
40
+ const [scrollOffset, setScrollOffset] = useState(0);
41
+ // Get sorted responder names
42
+ const responderNames = useMemo(() => Object.keys(editResponders).sort(), [editResponders]);
43
+ // Total options includes all responders plus "+ Add responder" option
44
+ const totalOptions = responderNames.length + 1;
45
+ // Calculate visible range for scrolling
46
+ const visibleCount = Math.min(maxHeight - 6, totalOptions); // Reserve lines for header, footer, hints
47
+ const visibleResponders = useMemo(() => {
48
+ const endIndex = Math.min(scrollOffset + visibleCount, responderNames.length);
49
+ return responderNames.slice(scrollOffset, endIndex);
50
+ }, [scrollOffset, visibleCount, responderNames]);
51
+ // Auto-scroll to keep highlighted item visible
52
+ React.useEffect(() => {
53
+ if (highlightedIndex < scrollOffset) {
54
+ setScrollOffset(highlightedIndex);
55
+ }
56
+ else if (highlightedIndex >= scrollOffset + visibleCount) {
57
+ setScrollOffset(Math.max(0, highlightedIndex - visibleCount + 1));
58
+ }
59
+ }, [highlightedIndex, scrollOffset, visibleCount]);
60
+ // Navigation handlers
61
+ const handleNavigateUp = useCallback(() => {
62
+ setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : totalOptions - 1));
63
+ }, [totalOptions]);
64
+ const handleNavigateDown = useCallback(() => {
65
+ setHighlightedIndex((prev) => (prev < totalOptions - 1 ? prev + 1 : 0));
66
+ }, [totalOptions]);
67
+ // Delete the highlighted responder
68
+ const handleDelete = useCallback(() => {
69
+ if (highlightedIndex < responderNames.length) {
70
+ const nameToDelete = responderNames[highlightedIndex];
71
+ const newResponders = { ...editResponders };
72
+ delete newResponders[nameToDelete];
73
+ setEditResponders(newResponders);
74
+ // Adjust highlighted index if needed
75
+ const newNames = Object.keys(newResponders);
76
+ if (highlightedIndex >= newNames.length && newNames.length > 0) {
77
+ setHighlightedIndex(newNames.length - 1);
78
+ }
79
+ else if (newNames.length === 0) {
80
+ setHighlightedIndex(0);
81
+ }
82
+ }
83
+ }, [highlightedIndex, responderNames, editResponders]);
84
+ // Start editing or adding a responder
85
+ const handleStartEdit = useCallback(() => {
86
+ if (highlightedIndex < responderNames.length) {
87
+ // Edit existing responder
88
+ const name = responderNames[highlightedIndex];
89
+ const config = editResponders[name];
90
+ setEditingResponder({ name, config: { ...config } });
91
+ setMode("edit-responder");
92
+ }
93
+ else {
94
+ // Add new responder - start with name input
95
+ setEditText("");
96
+ setMode("add-name");
97
+ }
98
+ }, [highlightedIndex, responderNames, editResponders]);
99
+ // Handle name submission when adding new responder
100
+ const handleNameSubmit = useCallback(() => {
101
+ const trimmedName = editText.trim();
102
+ if (trimmedName) {
103
+ // Check if name already exists
104
+ if (editResponders[trimmedName]) {
105
+ // Edit existing instead
106
+ const config = editResponders[trimmedName];
107
+ setEditingResponder({ name: trimmedName, config: { ...config } });
108
+ setMode("edit-responder");
109
+ }
110
+ else {
111
+ // Create new responder with default values
112
+ const defaultTrigger = trimmedName === "default" ? undefined : `@${trimmedName}`;
113
+ setEditingResponder({
114
+ name: trimmedName,
115
+ config: {
116
+ type: "llm",
117
+ trigger: defaultTrigger,
118
+ },
119
+ });
120
+ setTypeIndex(0);
121
+ setMode("select-type");
122
+ }
123
+ }
124
+ else {
125
+ setMode("list");
126
+ }
127
+ setEditText("");
128
+ }, [editText, editResponders]);
129
+ // Handle type selection
130
+ const handleTypeSelect = useCallback(() => {
131
+ if (editingResponder) {
132
+ const selectedType = RESPONDER_TYPES[typeIndex];
133
+ setEditingResponder({
134
+ ...editingResponder,
135
+ config: {
136
+ ...editingResponder.config,
137
+ type: selectedType,
138
+ timeout: DEFAULT_TIMEOUTS[selectedType],
139
+ maxLength: 2000,
140
+ },
141
+ });
142
+ setMode("edit-responder");
143
+ }
144
+ }, [editingResponder, typeIndex]);
145
+ // Save current editing responder
146
+ const saveEditingResponder = useCallback(() => {
147
+ if (editingResponder) {
148
+ const newResponders = {
149
+ ...editResponders,
150
+ [editingResponder.name]: { ...editingResponder.config },
151
+ };
152
+ // Clean up undefined values
153
+ const config = newResponders[editingResponder.name];
154
+ if (!config.trigger)
155
+ delete config.trigger;
156
+ if (!config.provider)
157
+ delete config.provider;
158
+ if (!config.systemPrompt)
159
+ delete config.systemPrompt;
160
+ if (!config.command)
161
+ delete config.command;
162
+ if (!config.timeout)
163
+ delete config.timeout;
164
+ if (!config.maxLength)
165
+ delete config.maxLength;
166
+ setEditResponders(newResponders);
167
+ setEditingResponder(null);
168
+ setMode("list");
169
+ setEditText("");
170
+ // Update highlighted index to the new/edited responder
171
+ const sortedNames = Object.keys(newResponders).sort();
172
+ const newIndex = sortedNames.indexOf(editingResponder.name);
173
+ if (newIndex >= 0) {
174
+ setHighlightedIndex(newIndex);
175
+ }
176
+ }
177
+ }, [editingResponder, editResponders]);
178
+ // Cancel editing
179
+ const handleCancel = useCallback(() => {
180
+ setMode("list");
181
+ setEditText("");
182
+ setEditingResponder(null);
183
+ }, []);
184
+ // Handle text field submission
185
+ const handleTextSubmit = useCallback(() => {
186
+ if (!editingResponder)
187
+ return;
188
+ const trimmedValue = editText.trim();
189
+ switch (mode) {
190
+ case "edit-trigger":
191
+ setEditingResponder({
192
+ ...editingResponder,
193
+ config: {
194
+ ...editingResponder.config,
195
+ trigger: trimmedValue || undefined,
196
+ },
197
+ });
198
+ break;
199
+ case "edit-provider":
200
+ setEditingResponder({
201
+ ...editingResponder,
202
+ config: {
203
+ ...editingResponder.config,
204
+ provider: trimmedValue || undefined,
205
+ },
206
+ });
207
+ break;
208
+ case "edit-system":
209
+ setEditingResponder({
210
+ ...editingResponder,
211
+ config: {
212
+ ...editingResponder.config,
213
+ systemPrompt: trimmedValue || undefined,
214
+ },
215
+ });
216
+ break;
217
+ case "edit-command":
218
+ setEditingResponder({
219
+ ...editingResponder,
220
+ config: {
221
+ ...editingResponder.config,
222
+ command: trimmedValue || undefined,
223
+ },
224
+ });
225
+ break;
226
+ case "edit-timeout":
227
+ setEditingResponder({
228
+ ...editingResponder,
229
+ config: {
230
+ ...editingResponder.config,
231
+ timeout: trimmedValue ? parseInt(trimmedValue, 10) || undefined : undefined,
232
+ },
233
+ });
234
+ break;
235
+ case "edit-maxlength":
236
+ setEditingResponder({
237
+ ...editingResponder,
238
+ config: {
239
+ ...editingResponder.config,
240
+ maxLength: trimmedValue ? parseInt(trimmedValue, 10) || undefined : undefined,
241
+ },
242
+ });
243
+ break;
244
+ }
245
+ setMode("edit-responder");
246
+ setEditText("");
247
+ }, [editingResponder, editText, mode]);
248
+ // Handle keyboard input for list mode
249
+ useInput((input, key) => {
250
+ if (!isFocused || mode !== "list")
251
+ return;
252
+ if (input === "j" || key.downArrow) {
253
+ handleNavigateDown();
254
+ }
255
+ else if (input === "k" || key.upArrow) {
256
+ handleNavigateUp();
257
+ }
258
+ else if (key.return || input === "e") {
259
+ handleStartEdit();
260
+ }
261
+ else if (input === "d" || key.delete) {
262
+ handleDelete();
263
+ }
264
+ else if (key.escape) {
265
+ onCancel();
266
+ }
267
+ else if (input === "s" || input === "S") {
268
+ onConfirm(editResponders);
269
+ }
270
+ }, { isActive: isFocused && mode === "list" });
271
+ // Handle keyboard input for type selection
272
+ useInput((input, key) => {
273
+ if (!isFocused || mode !== "select-type")
274
+ return;
275
+ if (input === "j" || key.downArrow) {
276
+ setTypeIndex((prev) => (prev < RESPONDER_TYPES.length - 1 ? prev + 1 : 0));
277
+ }
278
+ else if (input === "k" || key.upArrow) {
279
+ setTypeIndex((prev) => (prev > 0 ? prev - 1 : RESPONDER_TYPES.length - 1));
280
+ }
281
+ else if (key.return) {
282
+ handleTypeSelect();
283
+ }
284
+ else if (key.escape) {
285
+ handleCancel();
286
+ }
287
+ }, { isActive: isFocused && mode === "select-type" });
288
+ // Handle keyboard input for text editing modes
289
+ useInput((_input, key) => {
290
+ const textModes = [
291
+ "add-name",
292
+ "edit-trigger",
293
+ "edit-provider",
294
+ "edit-system",
295
+ "edit-command",
296
+ "edit-timeout",
297
+ "edit-maxlength",
298
+ ];
299
+ if (!isFocused || !textModes.includes(mode))
300
+ return;
301
+ if (key.escape) {
302
+ if (mode === "add-name") {
303
+ handleCancel();
304
+ }
305
+ else {
306
+ setMode("edit-responder");
307
+ setEditText("");
308
+ }
309
+ }
310
+ }, {
311
+ isActive: isFocused &&
312
+ [
313
+ "add-name",
314
+ "edit-trigger",
315
+ "edit-provider",
316
+ "edit-system",
317
+ "edit-command",
318
+ "edit-timeout",
319
+ "edit-maxlength",
320
+ ].includes(mode),
321
+ });
322
+ // Handle keyboard input for edit-responder mode (viewing a responder)
323
+ useInput((input, key) => {
324
+ if (!isFocused || mode !== "edit-responder" || !editingResponder)
325
+ return;
326
+ if (key.escape) {
327
+ handleCancel();
328
+ }
329
+ else if (input === "t" || input === "T") {
330
+ // Edit type
331
+ setTypeIndex(RESPONDER_TYPES.indexOf(editingResponder.config.type));
332
+ setMode("select-type");
333
+ }
334
+ else if (input === "g" || input === "G") {
335
+ // Edit trigger
336
+ setEditText(editingResponder.config.trigger || "");
337
+ setMode("edit-trigger");
338
+ }
339
+ else if (input === "p" || input === "P") {
340
+ // Edit provider (only for llm type)
341
+ if (editingResponder.config.type === "llm") {
342
+ setEditText(editingResponder.config.provider || "");
343
+ setMode("edit-provider");
344
+ }
345
+ }
346
+ else if (input === "y" || input === "Y") {
347
+ // Edit system prompt (only for llm type)
348
+ if (editingResponder.config.type === "llm") {
349
+ setEditText(editingResponder.config.systemPrompt || "");
350
+ setMode("edit-system");
351
+ }
352
+ }
353
+ else if (input === "c" || input === "C") {
354
+ // Edit command (only for cli type)
355
+ if (editingResponder.config.type === "cli") {
356
+ setEditText(editingResponder.config.command || "");
357
+ setMode("edit-command");
358
+ }
359
+ }
360
+ else if (input === "o" || input === "O") {
361
+ // Edit timeout
362
+ setEditText(editingResponder.config.timeout?.toString() || "");
363
+ setMode("edit-timeout");
364
+ }
365
+ else if (input === "l" || input === "L") {
366
+ // Edit max length
367
+ setEditText(editingResponder.config.maxLength?.toString() || "");
368
+ setMode("edit-maxlength");
369
+ }
370
+ else if (input === "s" || input === "S") {
371
+ // Save and close
372
+ saveEditingResponder();
373
+ }
374
+ }, { isActive: isFocused && mode === "edit-responder" });
375
+ // Render type selection mode
376
+ if (mode === "select-type") {
377
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Select Responder Type" }), editingResponder && _jsxs(Text, { dimColor: true, children: [" for \"", editingResponder.name, "\""] })] }), RESPONDER_TYPES.map((type, index) => {
378
+ const isHighlighted = index === typeIndex;
379
+ return (_jsxs(Box, { children: [_jsx(Text, { color: isHighlighted ? "cyan" : undefined, children: isHighlighted ? "▸ " : " " }), _jsx(Text, { bold: isHighlighted, color: isHighlighted ? "cyan" : undefined, inverse: isHighlighted, children: type }), _jsxs(Text, { dimColor: true, children: [" - ", TYPE_DESCRIPTIONS[type]] })] }, type));
380
+ }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "j/k: navigate | Enter: select | Esc: cancel" }) })] }));
381
+ }
382
+ // Render name input mode
383
+ if (mode === "add-name") {
384
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Add New Responder" }) }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Name: " }), _jsx(TextInput, { value: editText, onChange: setEditText, onSubmit: handleNameSubmit, focus: isFocused, placeholder: "e.g., qa, reviewer, code" })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Suggested: ", SUGGESTED_NAMES.join(", ")] }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter: next | Esc: cancel" }) })] }));
385
+ }
386
+ // Render trigger input mode
387
+ if (mode === "edit-trigger" && editingResponder) {
388
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Edit Trigger Pattern" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Use @name for mentions, leave empty for default handler" }) }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Trigger: " }), _jsx(TextInput, { value: editText, onChange: setEditText, onSubmit: handleTextSubmit, focus: isFocused, placeholder: "e.g., @qa, @review (or empty for default)" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter: save | Esc: cancel" }) })] }));
389
+ }
390
+ // Render provider input mode
391
+ if (mode === "edit-provider" && editingResponder) {
392
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Edit LLM Provider" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Name from llmProviders config (e.g., anthropic, openai)" }) }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Provider: " }), _jsx(TextInput, { value: editText, onChange: setEditText, onSubmit: handleTextSubmit, focus: isFocused, placeholder: "e.g., anthropic" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter: save | Esc: cancel" }) })] }));
393
+ }
394
+ // Render system prompt input mode
395
+ if (mode === "edit-system" && editingResponder) {
396
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Edit System Prompt" }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: ["Supports ", "{{project}}", " placeholder for project context"] }) }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "System: " }), _jsx(TextInput, { value: editText, onChange: setEditText, onSubmit: handleTextSubmit, focus: isFocused, placeholder: "You are a helpful assistant..." })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter: save | Esc: cancel" }) })] }));
397
+ }
398
+ // Render command input mode
399
+ if (mode === "edit-command" && editingResponder) {
400
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Edit CLI Command" }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: ["Supports ", "{{message}}", " placeholder for the user message"] }) }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Command: " }), _jsx(TextInput, { value: editText, onChange: setEditText, onSubmit: handleTextSubmit, focus: isFocused, placeholder: "e.g., npm run lint" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter: save | Esc: cancel" }) })] }));
401
+ }
402
+ // Render timeout input mode
403
+ if (mode === "edit-timeout" && editingResponder) {
404
+ const defaultTimeout = DEFAULT_TIMEOUTS[editingResponder.config.type];
405
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Edit Timeout" }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: ["Timeout in milliseconds (default: ", defaultTimeout, ")"] }) }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Timeout: " }), _jsx(TextInput, { value: editText, onChange: setEditText, onSubmit: handleTextSubmit, focus: isFocused, placeholder: defaultTimeout.toString() })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter: save | Esc: cancel" }) })] }));
406
+ }
407
+ // Render max length input mode
408
+ if (mode === "edit-maxlength" && editingResponder) {
409
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Edit Max Response Length" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Maximum characters to send back to chat (default: 2000)" }) }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Max Length: " }), _jsx(TextInput, { value: editText, onChange: setEditText, onSubmit: handleTextSubmit, focus: isFocused, placeholder: "2000" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter: save | Esc: cancel" }) })] }));
410
+ }
411
+ // Render edit-responder mode (viewing/editing a single responder)
412
+ if (mode === "edit-responder" && editingResponder) {
413
+ const config = editingResponder.config;
414
+ const isLLM = config.type === "llm";
415
+ const isCLI = config.type === "cli";
416
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "cyan", children: ["Edit Responder: ", editingResponder.name] }) }), _jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "[T] Type: " }), _jsx(Text, { children: config.type })] }), _jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "[G] Trigger: " }), _jsx(Text, { dimColor: !config.trigger, children: config.trigger || "(default handler)" })] }), isLLM && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "[P] Provider: " }), _jsx(Text, { dimColor: !config.provider, children: config.provider || "(not set)" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "[Y] System: " }), _jsx(Text, { dimColor: !config.systemPrompt, children: config.systemPrompt
417
+ ? config.systemPrompt.length > 40
418
+ ? config.systemPrompt.substring(0, 40) + "..."
419
+ : config.systemPrompt
420
+ : "(not set)" })] })] })), isCLI && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "[C] Command: " }), _jsx(Text, { dimColor: !config.command, children: config.command || "(not set)" })] })), _jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "[O] Timeout: " }), _jsxs(Text, { dimColor: !config.timeout, children: [config.timeout || DEFAULT_TIMEOUTS[config.type], "ms"] })] }), _jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "[L] Max Length: " }), _jsx(Text, { dimColor: !config.maxLength, children: config.maxLength || 2000 })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["[T]ype [G]trigger", isLLM ? " [P]rovider [Y]system" : "", isCLI ? " [C]ommand" : "", " [O]timeout [L]ength"] }), _jsx(Text, { dimColor: true, children: "S: save | Esc: cancel" })] })] }));
421
+ }
422
+ // Calculate scroll indicators
423
+ const canScrollUp = scrollOffset > 0;
424
+ const canScrollDown = scrollOffset + visibleCount < responderNames.length;
425
+ const hasOverflow = responderNames.length > visibleCount;
426
+ // Render list mode
427
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: label }), _jsxs(Text, { dimColor: true, children: [" (", responderNames.length, " responders)"] })] }), hasOverflow && (_jsx(Box, { children: _jsx(Text, { color: canScrollUp ? "cyan" : "gray", dimColor: !canScrollUp, children: canScrollUp ? " ▲ more" : "" }) })), responderNames.length === 0 ? (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, italic: true, children: "No responders configured" }) })) : (visibleResponders.map((name) => {
428
+ const actualIndex = responderNames.indexOf(name);
429
+ const isHighlighted = actualIndex === highlightedIndex;
430
+ const config = editResponders[name];
431
+ // Format display: type abbreviation and trigger
432
+ const typeAbbrev = config.type === "claude-code" ? "claude" : config.type;
433
+ const trigger = config.trigger || "(default)";
434
+ return (_jsxs(Box, { children: [_jsx(Text, { color: isHighlighted ? "cyan" : undefined, children: isHighlighted ? "▸ " : " " }), _jsx(Text, { bold: isHighlighted, color: isHighlighted ? "cyan" : "yellow", inverse: isHighlighted, children: name.padEnd(12) }), _jsx(Text, { color: "magenta", children: typeAbbrev.padEnd(8) }), _jsx(Text, { dimColor: true, children: trigger })] }, name));
435
+ })), hasOverflow && (_jsx(Box, { children: _jsx(Text, { color: canScrollDown ? "cyan" : "gray", dimColor: !canScrollDown, children: canScrollDown ? " ▼ more" : "" }) })), _jsxs(Box, { children: [_jsx(Text, { color: highlightedIndex === responderNames.length ? "green" : undefined, children: highlightedIndex === responderNames.length ? "▸ " : " " }), _jsx(Text, { bold: highlightedIndex === responderNames.length, color: highlightedIndex === responderNames.length ? "green" : "gray", inverse: highlightedIndex === responderNames.length, children: "+ Add responder" })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "j/k: navigate | Enter/e: edit | d: delete" }), _jsx(Text, { dimColor: true, children: "s: save all | Esc: cancel" })] })] }));
436
+ }
437
+ export default RespondersEditor;
@@ -16,7 +16,18 @@ export const CONFIG_SECTIONS = [
16
16
  id: "docker",
17
17
  label: "Docker",
18
18
  icon: "🐳",
19
- fields: ["docker.ports", "docker.volumes", "docker.environment", "docker.packages", "docker.git", "docker.buildCommands", "docker.startCommand", "docker.firewall", "docker.autoStart", "docker.restartCount"],
19
+ fields: [
20
+ "docker.ports",
21
+ "docker.volumes",
22
+ "docker.environment",
23
+ "docker.packages",
24
+ "docker.git",
25
+ "docker.buildCommands",
26
+ "docker.startCommand",
27
+ "docker.firewall",
28
+ "docker.autoStart",
29
+ "docker.restartCount",
30
+ ],
20
31
  },
21
32
  {
22
33
  id: "daemon",
@@ -34,13 +45,26 @@ export const CONFIG_SECTIONS = [
34
45
  id: "chat",
35
46
  label: "Chat",
36
47
  icon: "💬",
37
- fields: ["chat.enabled", "chat.provider", "chat.telegram", "chat.slack", "chat.discord"],
48
+ fields: ["chat.enabled", "chat.provider", "chat.telegram", "chat.slack", "chat.discord", "chat.responders"],
38
49
  },
39
50
  {
40
51
  id: "notifications",
41
52
  label: "Notifications",
42
53
  icon: "🔔",
43
- fields: ["notifications.provider", "notifications.ntfy", "notifications.pushover", "notifications.gotify", "notifications.command", "notifyCommand"],
54
+ fields: [
55
+ "notifications.provider",
56
+ "notifications.ntfy",
57
+ "notifications.pushover",
58
+ "notifications.gotify",
59
+ "notifications.command",
60
+ "notifyCommand",
61
+ ],
62
+ },
63
+ {
64
+ id: "llm",
65
+ label: "LLM Providers",
66
+ icon: "🧠",
67
+ fields: ["llmProviders"],
44
68
  },
45
69
  ];
46
70
  /**
@@ -73,6 +73,10 @@ export type ChatMessageHandler = (message: ChatMessage) => Promise<void>;
73
73
  export interface SendMessageOptions {
74
74
  /** Inline keyboard buttons (Telegram-specific) */
75
75
  inlineKeyboard?: InlineButton[][];
76
+ /** Thread timestamp for reply context (Slack-specific) */
77
+ threadTs?: string;
78
+ /** Message ID to reply to (Telegram-specific) */
79
+ replyToMessageId?: number;
76
80
  }
77
81
  /**
78
82
  * Inline button for chat messages.
@@ -28,7 +28,17 @@ export function parseCommand(text, message) {
28
28
  if (!trimmed)
29
29
  return null;
30
30
  // Valid commands
31
- const validCommands = ["run", "status", "add", "exec", "stop", "help", "start", "action", "claude"];
31
+ const validCommands = [
32
+ "run",
33
+ "status",
34
+ "add",
35
+ "exec",
36
+ "stop",
37
+ "help",
38
+ "start",
39
+ "action",
40
+ "claude",
41
+ ];
32
42
  // Check for slash command format: /command [args...]
33
43
  if (trimmed.startsWith("/")) {
34
44
  const parts = trimmed.slice(1).split(/\s+/);
@@ -64,10 +74,7 @@ export function parseCommand(text, message) {
64
74
  * would otherwise be interpreted as HTML tags and cause API errors.
65
75
  */
66
76
  export function escapeHtml(text) {
67
- return text
68
- .replace(/&/g, "&amp;")
69
- .replace(/</g, "&lt;")
70
- .replace(/>/g, "&gt;");
77
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
71
78
  }
72
79
  /**
73
80
  * Strip ANSI escape codes from a string.
@@ -6,6 +6,34 @@ export interface CliConfig {
6
6
  modelArgs?: string[];
7
7
  fileArgs?: string[];
8
8
  }
9
+ /**
10
+ * LLM Provider Types
11
+ * - anthropic: Anthropic Claude API
12
+ * - openai: OpenAI API (GPT models)
13
+ * - ollama: Local Ollama server
14
+ */
15
+ export type LLMProviderType = "anthropic" | "openai" | "ollama";
16
+ /**
17
+ * Configuration for a single LLM provider.
18
+ */
19
+ export interface LLMProviderConfig {
20
+ type: LLMProviderType;
21
+ model: string;
22
+ apiKey?: string;
23
+ baseUrl?: string;
24
+ }
25
+ /**
26
+ * Named LLM providers configuration.
27
+ * Providers can be referenced by name in responder configs.
28
+ *
29
+ * Example:
30
+ * {
31
+ * "claude": { "type": "anthropic", "model": "claude-sonnet-4-20250514" },
32
+ * "gpt4": { "type": "openai", "model": "gpt-4o" },
33
+ * "local": { "type": "ollama", "model": "llama3", "baseUrl": "http://localhost:11434" }
34
+ * }
35
+ */
36
+ export type LLMProvidersConfig = Record<string, LLMProviderConfig>;
9
37
  export interface McpServerConfig {
10
38
  command: string;
11
39
  args?: string[];
@@ -82,12 +110,46 @@ export interface DiscordChatSettings {
82
110
  allowedGuildIds?: string[];
83
111
  allowedChannelIds?: string[];
84
112
  }
113
+ /**
114
+ * Chat responder types.
115
+ * - llm: Send message to an LLM provider and return response
116
+ * - claude-code: Run Claude Code CLI with the message as prompt
117
+ * - cli: Execute a custom CLI command with the message
118
+ */
119
+ export type ResponderType = "llm" | "claude-code" | "cli";
120
+ /**
121
+ * Configuration for a single chat responder.
122
+ * Responders handle incoming chat messages based on trigger patterns.
123
+ */
124
+ export interface ResponderConfig {
125
+ type: ResponderType;
126
+ trigger?: string;
127
+ provider?: string;
128
+ systemPrompt?: string;
129
+ command?: string;
130
+ timeout?: number;
131
+ maxLength?: number;
132
+ }
133
+ /**
134
+ * Named responders configuration.
135
+ * The special name "default" handles messages that don't match any trigger.
136
+ *
137
+ * Example:
138
+ * {
139
+ * "default": { "type": "llm", "provider": "anthropic", "systemPrompt": "You are a helpful assistant." },
140
+ * "qa": { "type": "llm", "trigger": "@qa", "provider": "anthropic", "systemPrompt": "Answer questions about {{project}}." },
141
+ * "code": { "type": "claude-code", "trigger": "@code" },
142
+ * "lint": { "type": "cli", "trigger": "!lint", "command": "npm run lint" }
143
+ * }
144
+ */
145
+ export type RespondersConfig = Record<string, ResponderConfig>;
85
146
  export interface ChatConfig {
86
147
  enabled?: boolean;
87
148
  provider?: "telegram" | "slack" | "discord";
88
149
  telegram?: TelegramChatSettings;
89
150
  slack?: SlackChatSettings;
90
151
  discord?: DiscordChatSettings;
152
+ responders?: RespondersConfig;
91
153
  }
92
154
  export interface RalphConfig {
93
155
  language: string;
@@ -100,6 +162,7 @@ export interface RalphConfig {
100
162
  javaVersion?: number;
101
163
  cli?: CliConfig;
102
164
  cliProvider?: string;
165
+ llmProviders?: LLMProvidersConfig;
103
166
  docker?: {
104
167
  ports?: string[];
105
168
  volumes?: string[];
@@ -150,3 +213,24 @@ export declare function isRunningInContainer(): boolean;
150
213
  * Require container execution. Exits with error if not in container.
151
214
  */
152
215
  export declare function requireContainer(commandName: string): void;
216
+ /**
217
+ * Default LLM provider configurations.
218
+ * These are used when llmProviders is not specified in config.
219
+ * API keys are resolved from environment variables at runtime.
220
+ */
221
+ export declare const DEFAULT_LLM_PROVIDERS: LLMProvidersConfig;
222
+ /**
223
+ * Get the API key for an LLM provider.
224
+ * First checks the provider config, then falls back to environment variables.
225
+ */
226
+ export declare function getLLMProviderApiKey(provider: LLMProviderConfig): string | undefined;
227
+ /**
228
+ * Get the base URL for an LLM provider.
229
+ * Returns the configured baseUrl or the default for the provider type.
230
+ */
231
+ export declare function getLLMProviderBaseUrl(provider: LLMProviderConfig): string;
232
+ /**
233
+ * Get LLM providers from config, merging with defaults.
234
+ * User-defined providers override defaults with the same name.
235
+ */
236
+ export declare function getLLMProviders(config: RalphConfig): LLMProvidersConfig;