code-ollama 0.2.1 → 0.3.1

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/README.md CHANGED
@@ -1,4 +1,7 @@
1
- # code-ollama
1
+ > [!NOTE]
2
+ > TUI is under active development. APIs may change.
3
+
4
+ # Code Ollama
2
5
 
3
6
  [![NPM](https://nodei.co/npm/code-ollama.svg)](https://www.npmjs.com/package/code-ollama)
4
7
 
@@ -0,0 +1,616 @@
1
+ import { _ as REJECT, a as listModels, c as saveConfig, d as ROLE, f as PLAN_GENERATION_INSTRUCTION, g as APPROVE, h as NAME, i as executeTool, l as createSystemMessage, m as LABEL, n as READ_ONLY_TOOLS, o as streamChat, p as VERSION, r as TOOLS, s as loadConfig, t as DANGEROUS_TOOLS, u as HEADER_PREFIX, v as NAMES } from "../cli.js";
2
+ import { homedir } from "node:os";
3
+ import { Box, Text, render, useInput } from "ink";
4
+ import { useCallback, useEffect, useState } from "react";
5
+ import { Select, Spinner, TextInput } from "@inkjs/ui";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ //#region src/components/Messages.tsx
8
+ function getMessageColor(role) {
9
+ switch (role) {
10
+ case ROLE.USER: return "black";
11
+ case ROLE.ASSISTANT: return "blue";
12
+ case ROLE.SYSTEM: return "gray";
13
+ default: return;
14
+ }
15
+ }
16
+ function Messages({ messages, isLoading }) {
17
+ return /* @__PURE__ */ jsxs(Box, {
18
+ flexDirection: "column",
19
+ children: [messages.map((message, index) => /* @__PURE__ */ jsx(Box, {
20
+ marginBottom: 1,
21
+ children: /* @__PURE__ */ jsxs(Text, {
22
+ color: getMessageColor(message.role),
23
+ dimColor: message.role === ROLE.SYSTEM,
24
+ children: [message.role === ROLE.USER ? "> " : "", message.content]
25
+ })
26
+ }, index)), isLoading && messages[messages.length - 1]?.content === "" && /* @__PURE__ */ jsx(Box, {
27
+ marginTop: -1,
28
+ marginBottom: 1,
29
+ children: /* @__PURE__ */ jsx(Spinner, { label: "Thinking..." })
30
+ })]
31
+ });
32
+ }
33
+ //#endregion
34
+ //#region src/components/SelectPrompt.tsx
35
+ function SelectPrompt({ children, onEscape, ...selectProps }) {
36
+ useInput((_, key) => {
37
+ if (key.escape) onEscape?.();
38
+ });
39
+ return /* @__PURE__ */ jsxs(Box, {
40
+ flexDirection: "column",
41
+ children: [children, /* @__PURE__ */ jsx(Select, { ...selectProps })]
42
+ });
43
+ }
44
+ //#endregion
45
+ //#region src/components/PlanApproval.tsx
46
+ var options$1 = [
47
+ {
48
+ label: "Auto - Execute tools automatically",
49
+ value: NAME.AUTO
50
+ },
51
+ {
52
+ label: "Safe - Approve each tool",
53
+ value: NAME.SAFE
54
+ },
55
+ {
56
+ label: "Cancel - Continue planning",
57
+ value: NAME.PLAN
58
+ }
59
+ ];
60
+ function PlanApproval({ planContent, onModeChange }) {
61
+ return /* @__PURE__ */ jsx(SelectPrompt, {
62
+ options: options$1,
63
+ onChange: useCallback((value) => {
64
+ onModeChange(value);
65
+ }, [onModeChange]),
66
+ onEscape: useCallback(() => {
67
+ onModeChange(NAME.PLAN);
68
+ }, [onModeChange]),
69
+ children: /* @__PURE__ */ jsxs(Box, {
70
+ flexDirection: "column",
71
+ marginTop: 1,
72
+ children: [
73
+ /* @__PURE__ */ jsx(Text, {
74
+ bold: true,
75
+ color: "magenta",
76
+ children: "Plan Generated - Choose execution mode:"
77
+ }),
78
+ /* @__PURE__ */ jsx(Box, {
79
+ marginY: 1,
80
+ children: /* @__PURE__ */ jsx(Text, { children: planContent })
81
+ }),
82
+ /* @__PURE__ */ jsx(Text, {
83
+ dimColor: true,
84
+ children: "Select execution mode (↑↓ + Enter to confirm, Esc to cancel)"
85
+ })
86
+ ]
87
+ })
88
+ });
89
+ }
90
+ //#endregion
91
+ //#region src/components/ToolApproval.tsx
92
+ var options = [{
93
+ label: "Approve tool call",
94
+ value: APPROVE
95
+ }, {
96
+ label: "Reject tool call",
97
+ value: REJECT
98
+ }];
99
+ function ToolApproval({ toolCall, onDecision }) {
100
+ const handleChange = useCallback((value) => {
101
+ onDecision(value);
102
+ }, [onDecision]);
103
+ const handleEscape = useCallback(() => {
104
+ onDecision(REJECT);
105
+ }, [onDecision]);
106
+ const args = JSON.stringify(toolCall.function.arguments, null, 2);
107
+ return /* @__PURE__ */ jsxs(SelectPrompt, {
108
+ options,
109
+ onChange: handleChange,
110
+ onEscape: handleEscape,
111
+ children: [
112
+ /* @__PURE__ */ jsx(Text, {
113
+ color: "yellow",
114
+ children: "⚠️ Tool requires approval:"
115
+ }),
116
+ /* @__PURE__ */ jsxs(Box, {
117
+ marginX: 3,
118
+ marginBottom: 1,
119
+ flexDirection: "column",
120
+ children: [/* @__PURE__ */ jsxs(Text, { children: [
121
+ /* @__PURE__ */ jsx(Text, {
122
+ italic: true,
123
+ children: "Tool:"
124
+ }),
125
+ " ",
126
+ toolCall.function.name
127
+ ] }), /* @__PURE__ */ jsxs(Text, { children: [
128
+ /* @__PURE__ */ jsx(Text, {
129
+ italic: true,
130
+ children: "Arguments:"
131
+ }),
132
+ " ",
133
+ args
134
+ ] })]
135
+ }),
136
+ /* @__PURE__ */ jsx(Text, {
137
+ dimColor: true,
138
+ children: "Select approval action (↑↓ + Enter to confirm, Esc to reject)"
139
+ })
140
+ ]
141
+ });
142
+ }
143
+ //#endregion
144
+ //#region src/components/Chat/constants.ts
145
+ var ACTION_NOT_PERFORMED = "The requested action was NOT performed";
146
+ var PLAN_CHECKLIST_REMINDER = "Then display the execution plan as an unchecked Markdown checklist only";
147
+ var PLAN_EXECUTION_REMINDER = "Do not claim success and do not call write_file or run_shell until the user approves execution";
148
+ //#endregion
149
+ //#region src/components/Chat/Input.tsx
150
+ function Input({ isDisabled = false, onSubmit }) {
151
+ const [resetKey, setResetKey] = useState(0);
152
+ const handleSubmit = useCallback((input) => {
153
+ const trimmed = input.trim();
154
+ if (!trimmed) return;
155
+ onSubmit(trimmed);
156
+ setResetKey((key) => key + 1);
157
+ }, [onSubmit]);
158
+ return /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, { children: "> " }), /* @__PURE__ */ jsx(TextInput, {
159
+ isDisabled,
160
+ suggestions: NAMES,
161
+ onSubmit: handleSubmit
162
+ }, resetKey)] });
163
+ }
164
+ //#endregion
165
+ //#region src/components/Chat/plan.ts
166
+ function hasExecutablePlan(content) {
167
+ return content.split("\n").some((line) => {
168
+ const trimmedLine = line.trim();
169
+ return /^- \[ \] (write_file|edit_file|run_shell)\(/.test(trimmedLine);
170
+ });
171
+ }
172
+ //#endregion
173
+ //#region src/components/Chat/Chat.tsx
174
+ function Chat({ model, onCommand, mode, onModeChange }) {
175
+ const [messages, setMessages] = useState([createSystemMessage()]);
176
+ const [isLoading, setIsLoading] = useState(false);
177
+ const [pendingToolCall, setPendingToolCall] = useState(null);
178
+ const [pendingPlan, setPendingPlan] = useState(null);
179
+ const buildToolResultMessage = useCallback((toolName, result) => {
180
+ if (result.error?.startsWith("Tool not allowed:")) return {
181
+ role: ROLE.SYSTEM,
182
+ content: [
183
+ `Tool ${toolName} was blocked by execution policy`,
184
+ ACTION_NOT_PERFORMED,
185
+ `Blocked because ${result.error}`,
186
+ "Do not claim success. Either continue with allowed read-only tools or explain that approval/execution mode must change"
187
+ ].join("\n")
188
+ };
189
+ return {
190
+ role: ROLE.SYSTEM,
191
+ content: `Tool ${toolName} result:\n${result.content}${result.error ? `\nError: ${result.error}` : ""}`
192
+ };
193
+ }, []);
194
+ const buildPlanModeCorrectionMessage = useCallback((toolName) => ({
195
+ role: ROLE.SYSTEM,
196
+ content: [
197
+ `Plan mode policy: ${toolName} cannot be executed during planning`,
198
+ ACTION_NOT_PERFORMED,
199
+ "Continue by using only read-only tools for research if needed",
200
+ PLAN_CHECKLIST_REMINDER,
201
+ PLAN_EXECUTION_REMINDER
202
+ ].join("\n")
203
+ }), []);
204
+ const processStream = useCallback(async (currentMessages, executionMode = mode) => {
205
+ const assistantMessage = {
206
+ role: ROLE.ASSISTANT,
207
+ content: ""
208
+ };
209
+ setMessages((previousMessages) => [...previousMessages, assistantMessage]);
210
+ try {
211
+ for await (const chunk of streamChat(currentMessages, model, TOOLS)) if (chunk.type === "content") {
212
+ assistantMessage.content += chunk.content;
213
+ setMessages((previousMessages) => {
214
+ const newMessages = [...previousMessages];
215
+ newMessages[newMessages.length - 1] = { ...assistantMessage };
216
+ return newMessages;
217
+ });
218
+ } else if (chunk.type === "tool_calls") for (const toolCall of chunk.tool_calls) {
219
+ const requiresApproval = DANGEROUS_TOOLS.has(toolCall.function.name);
220
+ // v8 ignore start
221
+ const allowedTools = executionMode === NAME.PLAN ? READ_ONLY_TOOLS : void 0;
222
+ // v8 ignore stop
223
+ if (executionMode === NAME.SAFE && requiresApproval) {
224
+ setPendingToolCall(toolCall);
225
+ setIsLoading(false);
226
+ return;
227
+ }
228
+ const result = await executeTool(toolCall.function.name, toolCall.function.arguments, { allowedTools });
229
+ const toolResultMessage = buildToolResultMessage(toolCall.function.name, result);
230
+ const newMessages = [
231
+ ...currentMessages,
232
+ assistantMessage,
233
+ toolResultMessage
234
+ ];
235
+ setMessages((previousMessages) => [...previousMessages, toolResultMessage]);
236
+ await processStream(newMessages, executionMode);
237
+ return;
238
+ }
239
+ } catch (error) {
240
+ assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`;
241
+ setMessages((previousMessages) => {
242
+ const newMessages = [...previousMessages];
243
+ newMessages[newMessages.length - 1] = { ...assistantMessage };
244
+ return newMessages;
245
+ });
246
+ } finally {
247
+ setIsLoading(false);
248
+ }
249
+ }, [
250
+ buildToolResultMessage,
251
+ model,
252
+ mode
253
+ ]);
254
+ const processStreamReadOnly = useCallback(async (currentMessages) => {
255
+ const assistantMessage = {
256
+ role: ROLE.ASSISTANT,
257
+ content: ""
258
+ };
259
+ setMessages((previousMessages) => [...previousMessages, assistantMessage]);
260
+ try {
261
+ const readOnlyTools = TOOLS.filter((tool) => READ_ONLY_TOOLS.has(tool.function.name));
262
+ for await (const chunk of streamChat(currentMessages, model, readOnlyTools)) if (chunk.type === "content") {
263
+ assistantMessage.content += chunk.content;
264
+ setMessages((previousMessages) => {
265
+ const newMessages = [...previousMessages];
266
+ newMessages[newMessages.length - 1] = { ...assistantMessage };
267
+ return newMessages;
268
+ });
269
+ } else if (chunk.type === "tool_calls") for (const toolCall of chunk.tool_calls) {
270
+ if (!READ_ONLY_TOOLS.has(toolCall.function.name)) {
271
+ const correctionMessage = buildPlanModeCorrectionMessage(toolCall.function.name);
272
+ const newMessages = [
273
+ ...currentMessages,
274
+ assistantMessage,
275
+ correctionMessage
276
+ ];
277
+ setMessages((previousMessages) => [...previousMessages, correctionMessage]);
278
+ await processStreamReadOnly(newMessages);
279
+ return;
280
+ }
281
+ const result = await executeTool(toolCall.function.name, toolCall.function.arguments, { allowedTools: READ_ONLY_TOOLS });
282
+ const toolResultMessage = buildToolResultMessage(toolCall.function.name, result);
283
+ const newMessages = [
284
+ ...currentMessages,
285
+ assistantMessage,
286
+ toolResultMessage
287
+ ];
288
+ setMessages((previousMessages) => [...previousMessages, toolResultMessage]);
289
+ await processStreamReadOnly(newMessages);
290
+ return;
291
+ }
292
+ const planInstruction = {
293
+ role: ROLE.SYSTEM,
294
+ content: PLAN_GENERATION_INSTRUCTION
295
+ };
296
+ const planMessages = [
297
+ ...currentMessages,
298
+ assistantMessage,
299
+ planInstruction
300
+ ];
301
+ const planAssistantMessage = {
302
+ role: ROLE.ASSISTANT,
303
+ content: ""
304
+ };
305
+ setMessages((previousMessages) => [...previousMessages, planAssistantMessage]);
306
+ for await (const chunk of streamChat(planMessages, model, [])) if (chunk.type === "content") {
307
+ planAssistantMessage.content += chunk.content;
308
+ setMessages((previousMessages) => {
309
+ const newMessages = [...previousMessages];
310
+ newMessages[newMessages.length - 1] = { ...planAssistantMessage };
311
+ return newMessages;
312
+ });
313
+ }
314
+ if (hasExecutablePlan(planAssistantMessage.content)) setPendingPlan({
315
+ planContent: planAssistantMessage.content,
316
+ messages: [...planMessages, planAssistantMessage]
317
+ });
318
+ setIsLoading(false);
319
+ } catch (error) {
320
+ assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`;
321
+ setMessages((previousMessages) => {
322
+ const newMessages = [...previousMessages];
323
+ newMessages[newMessages.length - 1] = { ...assistantMessage };
324
+ return newMessages;
325
+ });
326
+ } finally {
327
+ setIsLoading(false);
328
+ }
329
+ }, [
330
+ buildPlanModeCorrectionMessage,
331
+ buildToolResultMessage,
332
+ model
333
+ ]);
334
+ const handlePlanApproval = useCallback(async (choice) => {
335
+ // v8 ignore next
336
+ if (!pendingPlan) return;
337
+ const { messages: planMessages } = pendingPlan;
338
+ setPendingPlan(null);
339
+ if (choice === NAME.PLAN) {
340
+ onModeChange(NAME.PLAN);
341
+ const cancelMessage = {
342
+ role: ROLE.SYSTEM,
343
+ content: "Continuing in Plan mode. No tools were executed."
344
+ };
345
+ setMessages((previousMessages) => [...previousMessages, cancelMessage]);
346
+ return;
347
+ }
348
+ const selectedMode = choice === NAME.AUTO ? NAME.AUTO : NAME.SAFE;
349
+ onModeChange(selectedMode);
350
+ setIsLoading(true);
351
+ const executeInstruction = {
352
+ role: ROLE.SYSTEM,
353
+ content: choice === NAME.AUTO ? "Execute the plan above. Use tools as needed without asking for further confirmation." : "Execute the plan above one step at a time. Wait for user approval before each tool call that modifies files or runs commands."
354
+ };
355
+ await processStream([...planMessages, executeInstruction], selectedMode);
356
+ }, [
357
+ onModeChange,
358
+ pendingPlan,
359
+ processStream
360
+ ]);
361
+ const handleToolApproval = useCallback(async (decision) => {
362
+ // v8 ignore next
363
+ if (!pendingToolCall) return;
364
+ const toolCall = pendingToolCall;
365
+ setPendingToolCall(null);
366
+ setIsLoading(true);
367
+ switch (decision) {
368
+ case APPROVE: {
369
+ const result = await executeTool(toolCall.function.name, toolCall.function.arguments);
370
+ const toolResultMessage = {
371
+ role: ROLE.SYSTEM,
372
+ content: `Tool ${toolCall.function.name} result:\n${result.content}${result.error ? `\nError: ${result.error}` : ""}`
373
+ };
374
+ const newMessages = [...messages, toolResultMessage];
375
+ setMessages((previousMessages) => [...previousMessages, toolResultMessage]);
376
+ await processStream(newMessages);
377
+ break;
378
+ }
379
+ case REJECT: {
380
+ const rejectionMessage = {
381
+ role: ROLE.SYSTEM,
382
+ content: `User declined to execute tool ${toolCall.function.name}`
383
+ };
384
+ const newMessages = [...messages, rejectionMessage];
385
+ setMessages((previousMessages) => [...previousMessages, rejectionMessage]);
386
+ await processStream(newMessages);
387
+ break;
388
+ }
389
+ }
390
+ }, [
391
+ pendingToolCall,
392
+ messages,
393
+ processStream
394
+ ]);
395
+ const handleSubmit = useCallback(async (value) => {
396
+ const userContent = value.trim();
397
+ if (!userContent) return;
398
+ if (userContent.startsWith("/")) {
399
+ onCommand(userContent);
400
+ return;
401
+ }
402
+ setIsLoading(true);
403
+ const userMessage = {
404
+ role: ROLE.USER,
405
+ content: userContent
406
+ };
407
+ setMessages((previousMessages) => [...previousMessages, userMessage]);
408
+ const updatedMessages = [...messages, userMessage];
409
+ if (mode === NAME.PLAN) await processStreamReadOnly(updatedMessages);
410
+ else await processStream(updatedMessages);
411
+ }, [
412
+ messages,
413
+ onCommand,
414
+ processStream,
415
+ processStreamReadOnly,
416
+ mode
417
+ ]);
418
+ return /* @__PURE__ */ jsxs(Box, {
419
+ flexDirection: "column",
420
+ children: [
421
+ /* @__PURE__ */ jsx(Messages, {
422
+ messages: messages.slice(1),
423
+ isLoading
424
+ }),
425
+ pendingPlan && /* @__PURE__ */ jsx(PlanApproval, {
426
+ planContent: pendingPlan.planContent,
427
+ onModeChange: (selectedMode) => void handlePlanApproval(selectedMode)
428
+ }),
429
+ !pendingPlan && pendingToolCall && /* @__PURE__ */ jsx(ToolApproval, {
430
+ toolCall: pendingToolCall,
431
+ onDecision: handleToolApproval
432
+ }),
433
+ !pendingPlan && !pendingToolCall && /* @__PURE__ */ jsx(Input, {
434
+ isDisabled: isLoading,
435
+ onSubmit: handleSubmit
436
+ })
437
+ ]
438
+ });
439
+ }
440
+ //#endregion
441
+ //#region src/components/Footer.tsx
442
+ function getModeColor(mode) {
443
+ switch (mode) {
444
+ case NAME.PLAN: return "blue";
445
+ case NAME.AUTO: return "red";
446
+ case NAME.SAFE:
447
+ default: return "green";
448
+ }
449
+ }
450
+ function Footer({ mode, onToggleMode }) {
451
+ useInput((_, key) => {
452
+ if (key.tab && key.shift) onToggleMode();
453
+ });
454
+ const modeLabel = LABEL[mode];
455
+ return /* @__PURE__ */ jsx(Box, {
456
+ justifyContent: "space-between",
457
+ marginTop: 1,
458
+ children: /* @__PURE__ */ jsxs(Text, {
459
+ dimColor: true,
460
+ children: [
461
+ "Mode: ",
462
+ /* @__PURE__ */ jsx(Text, {
463
+ color: getModeColor(mode),
464
+ children: modeLabel
465
+ }),
466
+ /* @__PURE__ */ jsx(Text, {
467
+ dimColor: true,
468
+ children: " (Shift+Tab to toggle)"
469
+ })
470
+ ]
471
+ })
472
+ });
473
+ }
474
+ //#endregion
475
+ //#region src/components/Header.tsx
476
+ function abbreviatePath(dir) {
477
+ const home = homedir();
478
+ return dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir;
479
+ }
480
+ function Header({ model }) {
481
+ const directory = abbreviatePath(process.cwd());
482
+ return /* @__PURE__ */ jsxs(Box, {
483
+ borderStyle: "round",
484
+ flexDirection: "column",
485
+ paddingX: 1,
486
+ children: [
487
+ /* @__PURE__ */ jsxs(Text, { children: [/* @__PURE__ */ jsxs(Text, {
488
+ bold: true,
489
+ children: [HEADER_PREFIX, "Code Ollama"]
490
+ }), /* @__PURE__ */ jsxs(Text, {
491
+ dimColor: true,
492
+ children: [
493
+ " (v",
494
+ VERSION,
495
+ ")"
496
+ ]
497
+ })] }),
498
+ /* @__PURE__ */ jsx(Text, { children: " " }),
499
+ /* @__PURE__ */ jsxs(Box, { children: [
500
+ /* @__PURE__ */ jsx(Text, {
501
+ dimColor: true,
502
+ children: "model:".padEnd(11)
503
+ }),
504
+ /* @__PURE__ */ jsxs(Text, { children: [model, " "] }),
505
+ /* @__PURE__ */ jsx(Text, {
506
+ color: "cyan",
507
+ children: "/model"
508
+ }),
509
+ /* @__PURE__ */ jsx(Text, {
510
+ dimColor: true,
511
+ children: " to switch"
512
+ })
513
+ ] }),
514
+ /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
515
+ dimColor: true,
516
+ children: "directory:".padEnd(11)
517
+ }), /* @__PURE__ */ jsx(Text, { children: directory })] })
518
+ ]
519
+ });
520
+ }
521
+ //#endregion
522
+ //#region src/components/ModelPicker.tsx
523
+ function ModelPicker({ currentModel, onSelect, onClose }) {
524
+ const [options, setOptions] = useState([]);
525
+ const [error, setError] = useState(null);
526
+ useInput((_, key) => {
527
+ if (options.length && key.return) setTimeout(onClose);
528
+ });
529
+ useEffect(() => {
530
+ async function load() {
531
+ try {
532
+ const models = await listModels();
533
+ if (models.includes(currentModel)) {
534
+ models.splice(models.indexOf(currentModel), 1);
535
+ models.unshift(currentModel);
536
+ }
537
+ setOptions(models.map((model) => ({
538
+ label: model,
539
+ value: model
540
+ })));
541
+ } catch (error) {
542
+ setError(error instanceof Error ? error.message : String(error));
543
+ }
544
+ }
545
+ load();
546
+ }, [currentModel]);
547
+ if (error) return /* @__PURE__ */ jsxs(Text, {
548
+ color: "red",
549
+ children: ["Error loading models: ", error]
550
+ });
551
+ if (!options.length) return /* @__PURE__ */ jsx(Spinner, { label: "Loading models..." });
552
+ return /* @__PURE__ */ jsx(SelectPrompt, {
553
+ options,
554
+ defaultValue: currentModel,
555
+ onChange: onSelect,
556
+ onEscape: onClose,
557
+ children: /* @__PURE__ */ jsx(Text, {
558
+ dimColor: true,
559
+ children: "Select a model (↑↓ + Enter to confirm, Esc to cancel)"
560
+ })
561
+ });
562
+ }
563
+ //#endregion
564
+ //#region src/components/App.tsx
565
+ function App() {
566
+ const [model, setModel] = useState(() => loadConfig().model);
567
+ const [picking, setPicking] = useState(false);
568
+ const [mode, setMode] = useState(NAME.SAFE);
569
+ const handleCommand = useCallback((command) => {
570
+ if (command === "/model") setPicking(true);
571
+ }, []);
572
+ const handleSelect = useCallback((selected) => {
573
+ setModel(selected);
574
+ saveConfig({ model: selected });
575
+ setPicking(false);
576
+ }, []);
577
+ const handleClose = useCallback(() => {
578
+ setPicking(false);
579
+ }, []);
580
+ return /* @__PURE__ */ jsxs(Box, {
581
+ flexDirection: "column",
582
+ children: [
583
+ /* @__PURE__ */ jsx(Header, { model }),
584
+ picking ? /* @__PURE__ */ jsx(ModelPicker, {
585
+ currentModel: model,
586
+ onSelect: handleSelect,
587
+ onClose: handleClose
588
+ }) : /* @__PURE__ */ jsx(Chat, {
589
+ model,
590
+ onCommand: handleCommand,
591
+ mode,
592
+ onModeChange: setMode
593
+ }),
594
+ /* @__PURE__ */ jsx(Footer, {
595
+ mode,
596
+ onToggleMode: () => {
597
+ setMode((mode) => {
598
+ switch (mode) {
599
+ case NAME.SAFE: return NAME.AUTO;
600
+ case NAME.AUTO: return NAME.PLAN;
601
+ case NAME.PLAN:
602
+ default: return NAME.SAFE;
603
+ }
604
+ });
605
+ }
606
+ })
607
+ ]
608
+ });
609
+ }
610
+ //#endregion
611
+ //#region src/tui.tsx
612
+ function renderApp() {
613
+ render(/* @__PURE__ */ jsx(App, {}));
614
+ }
615
+ //#endregion
616
+ export { renderApp };
package/dist/cli.js CHANGED
@@ -11,8 +11,24 @@ var NAMES = [{
11
11
  description: "switch the model"
12
12
  }].map(({ name }) => name);
13
13
  //#endregion
14
+ //#region src/constants/decision.ts
15
+ var APPROVE = "approve";
16
+ var REJECT = "reject";
17
+ //#endregion
18
+ //#region src/constants/mode.ts
19
+ var NAME$1 = {
20
+ SAFE: "safe",
21
+ AUTO: "auto",
22
+ PLAN: "plan"
23
+ };
24
+ var LABEL = {
25
+ safe: "Safe",
26
+ auto: "Auto",
27
+ plan: "Plan"
28
+ };
29
+ //#endregion
14
30
  //#region src/constants/package.ts
15
- var VERSION = "0.2.1";
31
+ var VERSION = "0.3.1";
16
32
  //#endregion
17
33
  //#region src/constants/prompt.ts
18
34
  var BASE_SYSTEM_PROMPT = `You are a coding assistant that helps users write, edit, and understand code. You have access to tools for reading files, writing files, running shell commands, and searching code
@@ -39,6 +55,19 @@ Always use tools when you need to:
39
55
  - Make file changes
40
56
  - Explore project structure
41
57
  - Search the codebase`;
58
+ var PLAN_GENERATION_INSTRUCTION = `Based on the research above, decide whether the user request needs code or shell execution
59
+
60
+ If the request needs changes or commands, respond with a plan checklist only
61
+ Do not execute any tools
62
+ Do not claim any action was performed
63
+ Display the plan as an unchecked Markdown checklist using only these forms:
64
+
65
+ - [ ] write_file("path/to/file", "content") - Brief description
66
+ - [ ] edit_file("path/to/file", "oldText", "newText") - Brief description
67
+ - [ ] run_shell("command") - Brief description
68
+
69
+ Only include write_file, edit_file, and run_shell tools in the checklist
70
+ If no execution is needed, answer normally`;
42
71
  //#endregion
43
72
  //#region src/constants/role.ts
44
73
  var ROLE = {
@@ -241,7 +270,13 @@ var TOOLS = [
241
270
  "end"
242
271
  ])
243
272
  ];
244
- var TOOLS_REQUIRING_APPROVAL = new Set([
273
+ var READ_ONLY_TOOLS = new Set([
274
+ NAME.READ_FILE,
275
+ NAME.LIST_DIR,
276
+ NAME.GREP_SEARCH,
277
+ NAME.VIEW_RANGE
278
+ ]);
279
+ var DANGEROUS_TOOLS = new Set([
245
280
  NAME.WRITE_FILE,
246
281
  NAME.EDIT_FILE,
247
282
  NAME.RUN_SHELL
@@ -249,7 +284,11 @@ var TOOLS_REQUIRING_APPROVAL = new Set([
249
284
  /**
250
285
  * Execute a tool by name with arguments
251
286
  */
252
- async function executeTool(name, args) {
287
+ async function executeTool(name, args, options) {
288
+ if (options?.allowedTools && !options.allowedTools.has(name)) return {
289
+ content: "",
290
+ error: `Tool not allowed: ${name}`
291
+ };
253
292
  switch (name) {
254
293
  case NAME.READ_FILE: return readFile(args.path);
255
294
  case NAME.WRITE_FILE: return writeFile(args.path, args.content);
@@ -480,7 +519,7 @@ async function processRunStream(messages, model) {
480
519
  }
481
520
  async function main(args = process.argv.slice(2)) {
482
521
  if (!args.length) {
483
- const { renderApp } = await import("./assets/tui-eBtT-5hi.js");
522
+ const { renderApp } = await import("./assets/tui-CVsodXv3.js");
484
523
  clear();
485
524
  renderApp();
486
525
  return;
@@ -503,4 +542,4 @@ function isEntrypoint(argv1 = process.argv[1]) {
503
542
  if (isEntrypoint()) main();
504
543
  /* v8 ignore stop */
505
544
  //#endregion
506
- export { streamChat as a, createSystemMessage as c, VERSION as d, NAMES as f, listModels as i, HEADER_PREFIX as l, main, TOOLS_REQUIRING_APPROVAL as n, loadConfig as o, executeTool as r, saveConfig as s, TOOLS as t, ROLE as u };
545
+ export { REJECT as _, listModels as a, saveConfig as c, ROLE as d, PLAN_GENERATION_INSTRUCTION as f, APPROVE as g, NAME$1 as h, executeTool as i, createSystemMessage as l, LABEL as m, main, READ_ONLY_TOOLS as n, streamChat as o, VERSION as p, TOOLS as r, loadConfig as s, DANGEROUS_TOOLS as t, HEADER_PREFIX as u, NAMES as v };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-ollama",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Ollama coding agent that runs in your terminal",
5
5
  "author": "Mark <mark@remarkablemark.org> (https://remarkablemark.org)",
6
6
  "type": "module",
@@ -43,7 +43,7 @@
43
43
  "cac": "7.0.0",
44
44
  "ink": "7.0.2",
45
45
  "ollama": "0.6.3",
46
- "react": "19.2.5"
46
+ "react": "19.2.6"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@commitlint/cli": "20.5.3",
@@ -59,7 +59,7 @@
59
59
  "globals": "17.6.0",
60
60
  "husky": "9.1.7",
61
61
  "ink-testing-library": "4.0.0",
62
- "lint-staged": "16.4.0",
62
+ "lint-staged": "17.0.2",
63
63
  "prettier": "3.8.3",
64
64
  "publint": "0.3.19",
65
65
  "tsx": "4.21.0",
@@ -1,370 +0,0 @@
1
- import { a as streamChat, c as createSystemMessage, d as VERSION, f as NAMES, i as listModels, l as HEADER_PREFIX, n as TOOLS_REQUIRING_APPROVAL, o as loadConfig, r as executeTool, s as saveConfig, t as TOOLS, u as ROLE } from "../cli.js";
2
- import { homedir } from "node:os";
3
- import { Box, Text, render, useInput } from "ink";
4
- import { useCallback, useEffect, useState } from "react";
5
- import { Select, Spinner, TextInput } from "@inkjs/ui";
6
- import { jsx, jsxs } from "react/jsx-runtime";
7
- //#region src/components/ChatInput.tsx
8
- function ChatInput({ isDisabled = false, onSubmit }) {
9
- const [resetKey, setResetKey] = useState(0);
10
- const handleSubmit = useCallback((input) => {
11
- const trimmed = input.trim();
12
- if (!trimmed) return;
13
- onSubmit(trimmed);
14
- setResetKey((key) => key + 1);
15
- }, [onSubmit]);
16
- return /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, { children: "> " }), /* @__PURE__ */ jsx(TextInput, {
17
- isDisabled,
18
- suggestions: NAMES,
19
- onSubmit: handleSubmit
20
- }, resetKey)] });
21
- }
22
- //#endregion
23
- //#region src/components/Messages.tsx
24
- function getMessageColor(role) {
25
- switch (role) {
26
- case ROLE.USER: return "black";
27
- case ROLE.ASSISTANT: return "blue";
28
- case ROLE.SYSTEM: return "gray";
29
- default: return;
30
- }
31
- }
32
- function Messages({ messages, isLoading }) {
33
- return /* @__PURE__ */ jsxs(Box, {
34
- flexDirection: "column",
35
- children: [messages.map((message, index) => /* @__PURE__ */ jsx(Box, {
36
- marginBottom: 1,
37
- children: /* @__PURE__ */ jsxs(Text, {
38
- color: getMessageColor(message.role),
39
- dimColor: message.role === ROLE.SYSTEM,
40
- children: [message.role === ROLE.USER ? "> " : "", message.content]
41
- })
42
- }, index)), isLoading && messages[messages.length - 1]?.content === "" && /* @__PURE__ */ jsx(Box, {
43
- marginTop: -1,
44
- marginBottom: 1,
45
- children: /* @__PURE__ */ jsx(Spinner, { label: "Thinking..." })
46
- })]
47
- });
48
- }
49
- //#endregion
50
- //#region src/components/ToolApproval.tsx
51
- function ToolApproval({ toolCall, onApprove, onReject }) {
52
- const [selected, setSelected] = useState("yes");
53
- useInput((_, key) => {
54
- if (key.return) if (selected === "yes") onApprove();
55
- else onReject();
56
- else if (key.leftArrow || key.rightArrow) setSelected((prev) => prev === "yes" ? "no" : "yes");
57
- // v8 ignore stop
58
- });
59
- const args = JSON.stringify(toolCall.function.arguments, null, 2);
60
- return /* @__PURE__ */ jsxs(Box, {
61
- flexDirection: "column",
62
- marginY: 1,
63
- children: [
64
- /* @__PURE__ */ jsx(Text, {
65
- color: "yellow",
66
- bold: true,
67
- children: "⚠️ Tool requires approval:"
68
- }),
69
- /* @__PURE__ */ jsxs(Box, {
70
- marginX: 2,
71
- flexDirection: "column",
72
- children: [/* @__PURE__ */ jsxs(Text, { children: [
73
- /* @__PURE__ */ jsx(Text, {
74
- bold: true,
75
- children: "Tool:"
76
- }),
77
- " ",
78
- toolCall.function.name
79
- ] }), /* @__PURE__ */ jsxs(Text, { children: [
80
- /* @__PURE__ */ jsx(Text, {
81
- bold: true,
82
- children: "Arguments:"
83
- }),
84
- " ",
85
- args
86
- ] })]
87
- }),
88
- /* @__PURE__ */ jsxs(Box, {
89
- marginTop: 1,
90
- gap: 2,
91
- children: [/* @__PURE__ */ jsx(Text, { children: /* @__PURE__ */ jsxs(Text, {
92
- color: selected === "yes" ? "green" : void 0,
93
- children: [selected === "yes" ? "▶ " : " ", "✓ Yes (Enter)"]
94
- }) }), /* @__PURE__ */ jsx(Text, { children: /* @__PURE__ */ jsxs(Text, {
95
- color: selected === "no" ? "red" : void 0,
96
- children: [selected === "no" ? "▶ " : " ", "✗ No (Esc)"]
97
- }) })]
98
- })
99
- ]
100
- });
101
- }
102
- //#endregion
103
- //#region src/components/Chat.tsx
104
- function Chat({ model, onCommand, autoExecute }) {
105
- const [messages, setMessages] = useState([createSystemMessage()]);
106
- const [isLoading, setIsLoading] = useState(false);
107
- const [pendingToolCall, setPendingToolCall] = useState(null);
108
- const processStream = useCallback(async (currentMessages) => {
109
- const assistantMessage = {
110
- role: ROLE.ASSISTANT,
111
- content: ""
112
- };
113
- setMessages((previousMessages) => [...previousMessages, assistantMessage]);
114
- try {
115
- for await (const chunk of streamChat(currentMessages, model, TOOLS)) if (chunk.type === "content") {
116
- assistantMessage.content += chunk.content;
117
- setMessages((previousMessages) => {
118
- const newMessages = [...previousMessages];
119
- newMessages[newMessages.length - 1] = { ...assistantMessage };
120
- return newMessages;
121
- });
122
- } else if (chunk.type === "tool_calls") for (const toolCall of chunk.tool_calls) {
123
- const requiresApproval = TOOLS_REQUIRING_APPROVAL.has(toolCall.function.name);
124
- if (!autoExecute && requiresApproval) {
125
- setPendingToolCall(toolCall);
126
- setIsLoading(false);
127
- return;
128
- }
129
- const result = await executeTool(toolCall.function.name, toolCall.function.arguments);
130
- const toolResultMessage = {
131
- role: ROLE.SYSTEM,
132
- content: `Tool ${toolCall.function.name} result:\n${result.content}${result.error ? `\nError: ${result.error}` : ""}`
133
- };
134
- const newMessages = [
135
- ...currentMessages,
136
- assistantMessage,
137
- toolResultMessage
138
- ];
139
- setMessages((previousMessages) => [...previousMessages, toolResultMessage]);
140
- await processStream(newMessages);
141
- return;
142
- }
143
- } catch (error) {
144
- assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`;
145
- setMessages((previousMessages) => {
146
- const newMessages = [...previousMessages];
147
- newMessages[newMessages.length - 1] = { ...assistantMessage };
148
- return newMessages;
149
- });
150
- } finally {
151
- setIsLoading(false);
152
- }
153
- }, [model, autoExecute]);
154
- const handleToolApproval = useCallback(async (approved) => {
155
- // v8 ignore next
156
- if (!pendingToolCall) return;
157
- const toolCall = pendingToolCall;
158
- setPendingToolCall(null);
159
- setIsLoading(true);
160
- if (approved) {
161
- const result = await executeTool(toolCall.function.name, toolCall.function.arguments);
162
- const toolResultMessage = {
163
- role: ROLE.SYSTEM,
164
- content: `Tool ${toolCall.function.name} result:\n${result.content}${result.error ? `\nError: ${result.error}` : ""}`
165
- };
166
- const newMessages = [...messages, toolResultMessage];
167
- setMessages((previousMessages) => [...previousMessages, toolResultMessage]);
168
- await processStream(newMessages);
169
- } else {
170
- const rejectionMessage = {
171
- role: ROLE.SYSTEM,
172
- content: `User declined to execute tool ${toolCall.function.name}`
173
- };
174
- const newMessages = [...messages, rejectionMessage];
175
- setMessages((previousMessages) => [...previousMessages, rejectionMessage]);
176
- await processStream(newMessages);
177
- }
178
- }, [
179
- pendingToolCall,
180
- messages,
181
- processStream
182
- ]);
183
- const handleSubmit = useCallback(async (value) => {
184
- const userContent = value.trim();
185
- if (!userContent) return;
186
- if (userContent.startsWith("/")) {
187
- onCommand(userContent);
188
- return;
189
- }
190
- setIsLoading(true);
191
- const userMessage = {
192
- role: ROLE.USER,
193
- content: userContent
194
- };
195
- setMessages((previousMessages) => [...previousMessages, userMessage]);
196
- await processStream([...messages, userMessage]);
197
- }, [
198
- messages,
199
- onCommand,
200
- processStream
201
- ]);
202
- return /* @__PURE__ */ jsxs(Box, {
203
- flexDirection: "column",
204
- children: [
205
- /* @__PURE__ */ jsx(Messages, {
206
- messages: messages.slice(1),
207
- isLoading
208
- }),
209
- pendingToolCall && /* @__PURE__ */ jsx(ToolApproval, {
210
- toolCall: pendingToolCall,
211
- onApprove: () => void handleToolApproval(true),
212
- onReject: () => void handleToolApproval(false)
213
- }),
214
- !pendingToolCall && /* @__PURE__ */ jsx(ChatInput, {
215
- isDisabled: isLoading,
216
- onSubmit: handleSubmit
217
- })
218
- ]
219
- });
220
- }
221
- //#endregion
222
- //#region src/components/Footer.tsx
223
- function Footer({ autoExecute, onToggleMode }) {
224
- useInput((_, key) => {
225
- if (key.tab && key.shift) onToggleMode();
226
- });
227
- return /* @__PURE__ */ jsx(Box, {
228
- justifyContent: "space-between",
229
- marginTop: 1,
230
- children: /* @__PURE__ */ jsxs(Text, {
231
- dimColor: true,
232
- children: [
233
- "Mode: ",
234
- autoExecute ? "Auto" : "Safe",
235
- " (Shift+Tab to toggle)"
236
- ]
237
- })
238
- });
239
- }
240
- //#endregion
241
- //#region src/components/Header.tsx
242
- function abbreviatePath(dir) {
243
- const home = homedir();
244
- return dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir;
245
- }
246
- function Header({ model }) {
247
- const directory = abbreviatePath(process.cwd());
248
- return /* @__PURE__ */ jsxs(Box, {
249
- borderStyle: "round",
250
- flexDirection: "column",
251
- paddingX: 1,
252
- children: [
253
- /* @__PURE__ */ jsxs(Text, { children: [/* @__PURE__ */ jsxs(Text, {
254
- bold: true,
255
- children: [HEADER_PREFIX, "Code Ollama"]
256
- }), /* @__PURE__ */ jsxs(Text, {
257
- dimColor: true,
258
- children: [
259
- " (v",
260
- VERSION,
261
- ")"
262
- ]
263
- })] }),
264
- /* @__PURE__ */ jsx(Text, { children: " " }),
265
- /* @__PURE__ */ jsxs(Box, { children: [
266
- /* @__PURE__ */ jsx(Text, {
267
- dimColor: true,
268
- children: "model:".padEnd(11)
269
- }),
270
- /* @__PURE__ */ jsxs(Text, { children: [model, " "] }),
271
- /* @__PURE__ */ jsx(Text, {
272
- color: "cyan",
273
- children: "/model"
274
- }),
275
- /* @__PURE__ */ jsx(Text, {
276
- dimColor: true,
277
- children: " to switch"
278
- })
279
- ] }),
280
- /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
281
- dimColor: true,
282
- children: "directory:".padEnd(11)
283
- }), /* @__PURE__ */ jsx(Text, { children: directory })] })
284
- ]
285
- });
286
- }
287
- //#endregion
288
- //#region src/components/ModelPicker.tsx
289
- function ModelPicker({ currentModel, onSelect, onCancel }) {
290
- const [options, setOptions] = useState([]);
291
- const [error, setError] = useState(null);
292
- useEffect(() => {
293
- async function load() {
294
- try {
295
- setOptions((await listModels()).map((name) => ({
296
- label: name,
297
- value: name
298
- })));
299
- } catch (err) {
300
- setError(err instanceof Error ? err.message : String(err));
301
- }
302
- }
303
- load();
304
- }, []);
305
- useInput((_, key) => {
306
- if (key.escape) onCancel();
307
- });
308
- if (error) return /* @__PURE__ */ jsxs(Text, {
309
- color: "red",
310
- children: ["Error loading models: ", error]
311
- });
312
- if (!options.length) return /* @__PURE__ */ jsx(Spinner, { label: "Loading models..." });
313
- return /* @__PURE__ */ jsxs(Box, {
314
- flexDirection: "column",
315
- children: [/* @__PURE__ */ jsx(Text, {
316
- dimColor: true,
317
- children: "Select a model (↑↓ + Enter to confirm, Esc to cancel)"
318
- }), /* @__PURE__ */ jsx(Select, {
319
- options,
320
- defaultValue: currentModel,
321
- onChange: onSelect
322
- })]
323
- });
324
- }
325
- //#endregion
326
- //#region src/components/App.tsx
327
- function App() {
328
- const [model, setModel] = useState(() => loadConfig().model);
329
- const [picking, setPicking] = useState(false);
330
- const [autoExecute, setAutoExecute] = useState(false);
331
- const handleCommand = useCallback((command) => {
332
- if (command === "/model") setPicking(true);
333
- }, []);
334
- const handleSelect = useCallback((selected) => {
335
- setModel(selected);
336
- saveConfig({ model: selected });
337
- setPicking(false);
338
- }, []);
339
- const handleCancel = useCallback(() => {
340
- setPicking(false);
341
- }, []);
342
- return /* @__PURE__ */ jsxs(Box, {
343
- flexDirection: "column",
344
- children: [
345
- /* @__PURE__ */ jsx(Header, { model }),
346
- picking ? /* @__PURE__ */ jsx(ModelPicker, {
347
- currentModel: model,
348
- onSelect: handleSelect,
349
- onCancel: handleCancel
350
- }) : /* @__PURE__ */ jsx(Chat, {
351
- model,
352
- onCommand: handleCommand,
353
- autoExecute
354
- }),
355
- /* @__PURE__ */ jsx(Footer, {
356
- autoExecute,
357
- onToggleMode: () => {
358
- setAutoExecute((isAutoExecute) => !isAutoExecute);
359
- }
360
- })
361
- ]
362
- });
363
- }
364
- //#endregion
365
- //#region src/tui.tsx
366
- function renderApp() {
367
- render(/* @__PURE__ */ jsx(App, {}));
368
- }
369
- //#endregion
370
- export { renderApp };