code-ollama 0.2.1 → 0.3.0

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,604 @@
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/PlanApproval.tsx
35
+ var options$1 = [
36
+ {
37
+ label: "Auto - Execute tools automatically",
38
+ value: NAME.AUTO
39
+ },
40
+ {
41
+ label: "Safe - Approve each tool",
42
+ value: NAME.SAFE
43
+ },
44
+ {
45
+ label: "Cancel - Continue planning",
46
+ value: NAME.PLAN
47
+ }
48
+ ];
49
+ function PlanApproval({ planContent, onModeChange }) {
50
+ useInput((_, key) => {
51
+ if (key.escape) onModeChange(NAME.PLAN);
52
+ });
53
+ const handleChange = useCallback((value) => {
54
+ onModeChange(value);
55
+ }, [onModeChange]);
56
+ return /* @__PURE__ */ jsxs(Box, {
57
+ flexDirection: "column",
58
+ marginTop: 1,
59
+ children: [
60
+ /* @__PURE__ */ jsx(Text, {
61
+ bold: true,
62
+ color: "magenta",
63
+ children: "Plan Generated - Choose execution mode:"
64
+ }),
65
+ /* @__PURE__ */ jsx(Box, {
66
+ marginY: 1,
67
+ children: /* @__PURE__ */ jsx(Text, { children: planContent })
68
+ }),
69
+ /* @__PURE__ */ jsx(Text, {
70
+ dimColor: true,
71
+ children: "Select execution mode (↑↓ + Enter to confirm, Esc to cancel)"
72
+ }),
73
+ /* @__PURE__ */ jsx(Select, {
74
+ options: options$1,
75
+ onChange: handleChange
76
+ })
77
+ ]
78
+ });
79
+ }
80
+ //#endregion
81
+ //#region src/components/ToolApproval.tsx
82
+ var options = [{
83
+ label: "Approve tool call",
84
+ value: APPROVE
85
+ }, {
86
+ label: "Reject tool call",
87
+ value: REJECT
88
+ }];
89
+ function ToolApproval({ toolCall, onDecision }) {
90
+ useInput((_, key) => {
91
+ if (key.escape) onDecision(REJECT);
92
+ });
93
+ const handleChange = useCallback((value) => {
94
+ onDecision(value);
95
+ }, [onDecision]);
96
+ const args = JSON.stringify(toolCall.function.arguments, null, 2);
97
+ return /* @__PURE__ */ jsxs(Box, {
98
+ flexDirection: "column",
99
+ children: [
100
+ /* @__PURE__ */ jsx(Text, {
101
+ color: "yellow",
102
+ children: "⚠️ Tool requires approval:"
103
+ }),
104
+ /* @__PURE__ */ jsxs(Box, {
105
+ marginX: 3,
106
+ marginBottom: 1,
107
+ flexDirection: "column",
108
+ children: [/* @__PURE__ */ jsxs(Text, { children: [
109
+ /* @__PURE__ */ jsx(Text, {
110
+ italic: true,
111
+ children: "Tool:"
112
+ }),
113
+ " ",
114
+ toolCall.function.name
115
+ ] }), /* @__PURE__ */ jsxs(Text, { children: [
116
+ /* @__PURE__ */ jsx(Text, {
117
+ italic: true,
118
+ children: "Arguments:"
119
+ }),
120
+ " ",
121
+ args
122
+ ] })]
123
+ }),
124
+ /* @__PURE__ */ jsx(Text, {
125
+ dimColor: true,
126
+ children: "Select approval action (↑↓ + Enter to confirm, Esc to reject)"
127
+ }),
128
+ /* @__PURE__ */ jsx(Select, {
129
+ options,
130
+ onChange: handleChange
131
+ })
132
+ ]
133
+ });
134
+ }
135
+ //#endregion
136
+ //#region src/components/Chat/constants.ts
137
+ var ACTION_NOT_PERFORMED = "The requested action was NOT performed";
138
+ var PLAN_CHECKLIST_REMINDER = "Then display the execution plan as an unchecked Markdown checklist only";
139
+ var PLAN_EXECUTION_REMINDER = "Do not claim success and do not call write_file or run_shell until the user approves execution";
140
+ //#endregion
141
+ //#region src/components/Chat/Input.tsx
142
+ function Input({ isDisabled = false, onSubmit }) {
143
+ const [resetKey, setResetKey] = useState(0);
144
+ const handleSubmit = useCallback((input) => {
145
+ const trimmed = input.trim();
146
+ if (!trimmed) return;
147
+ onSubmit(trimmed);
148
+ setResetKey((key) => key + 1);
149
+ }, [onSubmit]);
150
+ return /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, { children: "> " }), /* @__PURE__ */ jsx(TextInput, {
151
+ isDisabled,
152
+ suggestions: NAMES,
153
+ onSubmit: handleSubmit
154
+ }, resetKey)] });
155
+ }
156
+ //#endregion
157
+ //#region src/components/Chat/plan.ts
158
+ function hasExecutablePlan(content) {
159
+ return content.split("\n").some((line) => {
160
+ const trimmedLine = line.trim();
161
+ return /^- \[ \] (write_file|edit_file|run_shell)\(/.test(trimmedLine);
162
+ });
163
+ }
164
+ //#endregion
165
+ //#region src/components/Chat/Chat.tsx
166
+ function Chat({ model, onCommand, mode, onModeChange }) {
167
+ const [messages, setMessages] = useState([createSystemMessage()]);
168
+ const [isLoading, setIsLoading] = useState(false);
169
+ const [pendingToolCall, setPendingToolCall] = useState(null);
170
+ const [pendingPlan, setPendingPlan] = useState(null);
171
+ const buildToolResultMessage = useCallback((toolName, result) => {
172
+ if (result.error?.startsWith("Tool not allowed:")) return {
173
+ role: ROLE.SYSTEM,
174
+ content: [
175
+ `Tool ${toolName} was blocked by execution policy`,
176
+ ACTION_NOT_PERFORMED,
177
+ `Blocked because ${result.error}`,
178
+ "Do not claim success. Either continue with allowed read-only tools or explain that approval/execution mode must change"
179
+ ].join("\n")
180
+ };
181
+ return {
182
+ role: ROLE.SYSTEM,
183
+ content: `Tool ${toolName} result:\n${result.content}${result.error ? `\nError: ${result.error}` : ""}`
184
+ };
185
+ }, []);
186
+ const buildPlanModeCorrectionMessage = useCallback((toolName) => ({
187
+ role: ROLE.SYSTEM,
188
+ content: [
189
+ `Plan mode policy: ${toolName} cannot be executed during planning`,
190
+ ACTION_NOT_PERFORMED,
191
+ "Continue by using only read-only tools for research if needed",
192
+ PLAN_CHECKLIST_REMINDER,
193
+ PLAN_EXECUTION_REMINDER
194
+ ].join("\n")
195
+ }), []);
196
+ const processStream = useCallback(async (currentMessages, executionMode = mode) => {
197
+ const assistantMessage = {
198
+ role: ROLE.ASSISTANT,
199
+ content: ""
200
+ };
201
+ setMessages((previousMessages) => [...previousMessages, assistantMessage]);
202
+ try {
203
+ for await (const chunk of streamChat(currentMessages, model, TOOLS)) if (chunk.type === "content") {
204
+ assistantMessage.content += chunk.content;
205
+ setMessages((previousMessages) => {
206
+ const newMessages = [...previousMessages];
207
+ newMessages[newMessages.length - 1] = { ...assistantMessage };
208
+ return newMessages;
209
+ });
210
+ } else if (chunk.type === "tool_calls") for (const toolCall of chunk.tool_calls) {
211
+ const requiresApproval = DANGEROUS_TOOLS.has(toolCall.function.name);
212
+ // v8 ignore start
213
+ const allowedTools = executionMode === NAME.PLAN ? READ_ONLY_TOOLS : void 0;
214
+ // v8 ignore stop
215
+ if (executionMode === NAME.SAFE && requiresApproval) {
216
+ setPendingToolCall(toolCall);
217
+ setIsLoading(false);
218
+ return;
219
+ }
220
+ const result = await executeTool(toolCall.function.name, toolCall.function.arguments, { allowedTools });
221
+ const toolResultMessage = buildToolResultMessage(toolCall.function.name, result);
222
+ const newMessages = [
223
+ ...currentMessages,
224
+ assistantMessage,
225
+ toolResultMessage
226
+ ];
227
+ setMessages((previousMessages) => [...previousMessages, toolResultMessage]);
228
+ await processStream(newMessages, executionMode);
229
+ return;
230
+ }
231
+ } catch (error) {
232
+ assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`;
233
+ setMessages((previousMessages) => {
234
+ const newMessages = [...previousMessages];
235
+ newMessages[newMessages.length - 1] = { ...assistantMessage };
236
+ return newMessages;
237
+ });
238
+ } finally {
239
+ setIsLoading(false);
240
+ }
241
+ }, [
242
+ buildToolResultMessage,
243
+ model,
244
+ mode
245
+ ]);
246
+ const processStreamReadOnly = useCallback(async (currentMessages) => {
247
+ const assistantMessage = {
248
+ role: ROLE.ASSISTANT,
249
+ content: ""
250
+ };
251
+ setMessages((previousMessages) => [...previousMessages, assistantMessage]);
252
+ try {
253
+ const readOnlyTools = TOOLS.filter((tool) => READ_ONLY_TOOLS.has(tool.function.name));
254
+ for await (const chunk of streamChat(currentMessages, model, readOnlyTools)) if (chunk.type === "content") {
255
+ assistantMessage.content += chunk.content;
256
+ setMessages((previousMessages) => {
257
+ const newMessages = [...previousMessages];
258
+ newMessages[newMessages.length - 1] = { ...assistantMessage };
259
+ return newMessages;
260
+ });
261
+ } else if (chunk.type === "tool_calls") for (const toolCall of chunk.tool_calls) {
262
+ if (!READ_ONLY_TOOLS.has(toolCall.function.name)) {
263
+ const correctionMessage = buildPlanModeCorrectionMessage(toolCall.function.name);
264
+ const newMessages = [
265
+ ...currentMessages,
266
+ assistantMessage,
267
+ correctionMessage
268
+ ];
269
+ setMessages((previousMessages) => [...previousMessages, correctionMessage]);
270
+ await processStreamReadOnly(newMessages);
271
+ return;
272
+ }
273
+ const result = await executeTool(toolCall.function.name, toolCall.function.arguments, { allowedTools: READ_ONLY_TOOLS });
274
+ const toolResultMessage = buildToolResultMessage(toolCall.function.name, result);
275
+ const newMessages = [
276
+ ...currentMessages,
277
+ assistantMessage,
278
+ toolResultMessage
279
+ ];
280
+ setMessages((previousMessages) => [...previousMessages, toolResultMessage]);
281
+ await processStreamReadOnly(newMessages);
282
+ return;
283
+ }
284
+ const planInstruction = {
285
+ role: ROLE.SYSTEM,
286
+ content: PLAN_GENERATION_INSTRUCTION
287
+ };
288
+ const planMessages = [
289
+ ...currentMessages,
290
+ assistantMessage,
291
+ planInstruction
292
+ ];
293
+ const planAssistantMessage = {
294
+ role: ROLE.ASSISTANT,
295
+ content: ""
296
+ };
297
+ setMessages((previousMessages) => [...previousMessages, planAssistantMessage]);
298
+ for await (const chunk of streamChat(planMessages, model, [])) if (chunk.type === "content") {
299
+ planAssistantMessage.content += chunk.content;
300
+ setMessages((previousMessages) => {
301
+ const newMessages = [...previousMessages];
302
+ newMessages[newMessages.length - 1] = { ...planAssistantMessage };
303
+ return newMessages;
304
+ });
305
+ }
306
+ if (hasExecutablePlan(planAssistantMessage.content)) setPendingPlan({
307
+ planContent: planAssistantMessage.content,
308
+ messages: [...planMessages, planAssistantMessage]
309
+ });
310
+ setIsLoading(false);
311
+ } catch (error) {
312
+ assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`;
313
+ setMessages((previousMessages) => {
314
+ const newMessages = [...previousMessages];
315
+ newMessages[newMessages.length - 1] = { ...assistantMessage };
316
+ return newMessages;
317
+ });
318
+ } finally {
319
+ setIsLoading(false);
320
+ }
321
+ }, [
322
+ buildPlanModeCorrectionMessage,
323
+ buildToolResultMessage,
324
+ model
325
+ ]);
326
+ const handlePlanApproval = useCallback(async (choice) => {
327
+ // v8 ignore next
328
+ if (!pendingPlan) return;
329
+ const { messages: planMessages } = pendingPlan;
330
+ setPendingPlan(null);
331
+ if (choice === NAME.PLAN) {
332
+ onModeChange(NAME.PLAN);
333
+ const cancelMessage = {
334
+ role: ROLE.SYSTEM,
335
+ content: "Continuing in Plan mode. No tools were executed."
336
+ };
337
+ setMessages((previousMessages) => [...previousMessages, cancelMessage]);
338
+ return;
339
+ }
340
+ const selectedMode = choice === NAME.AUTO ? NAME.AUTO : NAME.SAFE;
341
+ onModeChange(selectedMode);
342
+ setIsLoading(true);
343
+ const executeInstruction = {
344
+ role: ROLE.SYSTEM,
345
+ 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."
346
+ };
347
+ await processStream([...planMessages, executeInstruction], selectedMode);
348
+ }, [
349
+ onModeChange,
350
+ pendingPlan,
351
+ processStream
352
+ ]);
353
+ const handleToolApproval = useCallback(async (decision) => {
354
+ // v8 ignore next
355
+ if (!pendingToolCall) return;
356
+ const toolCall = pendingToolCall;
357
+ setPendingToolCall(null);
358
+ setIsLoading(true);
359
+ switch (decision) {
360
+ case APPROVE: {
361
+ const result = await executeTool(toolCall.function.name, toolCall.function.arguments);
362
+ const toolResultMessage = {
363
+ role: ROLE.SYSTEM,
364
+ content: `Tool ${toolCall.function.name} result:\n${result.content}${result.error ? `\nError: ${result.error}` : ""}`
365
+ };
366
+ const newMessages = [...messages, toolResultMessage];
367
+ setMessages((previousMessages) => [...previousMessages, toolResultMessage]);
368
+ await processStream(newMessages);
369
+ break;
370
+ }
371
+ case REJECT: {
372
+ const rejectionMessage = {
373
+ role: ROLE.SYSTEM,
374
+ content: `User declined to execute tool ${toolCall.function.name}`
375
+ };
376
+ const newMessages = [...messages, rejectionMessage];
377
+ setMessages((previousMessages) => [...previousMessages, rejectionMessage]);
378
+ await processStream(newMessages);
379
+ break;
380
+ }
381
+ }
382
+ }, [
383
+ pendingToolCall,
384
+ messages,
385
+ processStream
386
+ ]);
387
+ const handleSubmit = useCallback(async (value) => {
388
+ const userContent = value.trim();
389
+ if (!userContent) return;
390
+ if (userContent.startsWith("/")) {
391
+ onCommand(userContent);
392
+ return;
393
+ }
394
+ setIsLoading(true);
395
+ const userMessage = {
396
+ role: ROLE.USER,
397
+ content: userContent
398
+ };
399
+ setMessages((previousMessages) => [...previousMessages, userMessage]);
400
+ const updatedMessages = [...messages, userMessage];
401
+ if (mode === NAME.PLAN) await processStreamReadOnly(updatedMessages);
402
+ else await processStream(updatedMessages);
403
+ }, [
404
+ messages,
405
+ onCommand,
406
+ processStream,
407
+ processStreamReadOnly,
408
+ mode
409
+ ]);
410
+ return /* @__PURE__ */ jsxs(Box, {
411
+ flexDirection: "column",
412
+ children: [
413
+ /* @__PURE__ */ jsx(Messages, {
414
+ messages: messages.slice(1),
415
+ isLoading
416
+ }),
417
+ pendingPlan && /* @__PURE__ */ jsx(PlanApproval, {
418
+ planContent: pendingPlan.planContent,
419
+ onModeChange: (selectedMode) => void handlePlanApproval(selectedMode)
420
+ }),
421
+ !pendingPlan && pendingToolCall && /* @__PURE__ */ jsx(ToolApproval, {
422
+ toolCall: pendingToolCall,
423
+ onDecision: handleToolApproval
424
+ }),
425
+ !pendingPlan && !pendingToolCall && /* @__PURE__ */ jsx(Input, {
426
+ isDisabled: isLoading,
427
+ onSubmit: handleSubmit
428
+ })
429
+ ]
430
+ });
431
+ }
432
+ //#endregion
433
+ //#region src/components/Footer.tsx
434
+ function getModeColor(mode) {
435
+ switch (mode) {
436
+ case NAME.PLAN: return "blue";
437
+ case NAME.AUTO: return "red";
438
+ case NAME.SAFE:
439
+ default: return "green";
440
+ }
441
+ }
442
+ function Footer({ mode, onToggleMode }) {
443
+ useInput((_, key) => {
444
+ if (key.tab && key.shift) onToggleMode();
445
+ });
446
+ const modeLabel = LABEL[mode];
447
+ return /* @__PURE__ */ jsx(Box, {
448
+ justifyContent: "space-between",
449
+ marginTop: 1,
450
+ children: /* @__PURE__ */ jsxs(Text, {
451
+ dimColor: true,
452
+ children: [
453
+ "Mode: ",
454
+ /* @__PURE__ */ jsx(Text, {
455
+ color: getModeColor(mode),
456
+ children: modeLabel
457
+ }),
458
+ /* @__PURE__ */ jsx(Text, {
459
+ dimColor: true,
460
+ children: " (Shift+Tab to toggle)"
461
+ })
462
+ ]
463
+ })
464
+ });
465
+ }
466
+ //#endregion
467
+ //#region src/components/Header.tsx
468
+ function abbreviatePath(dir) {
469
+ const home = homedir();
470
+ return dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir;
471
+ }
472
+ function Header({ model }) {
473
+ const directory = abbreviatePath(process.cwd());
474
+ return /* @__PURE__ */ jsxs(Box, {
475
+ borderStyle: "round",
476
+ flexDirection: "column",
477
+ paddingX: 1,
478
+ children: [
479
+ /* @__PURE__ */ jsxs(Text, { children: [/* @__PURE__ */ jsxs(Text, {
480
+ bold: true,
481
+ children: [HEADER_PREFIX, "Code Ollama"]
482
+ }), /* @__PURE__ */ jsxs(Text, {
483
+ dimColor: true,
484
+ children: [
485
+ " (v",
486
+ VERSION,
487
+ ")"
488
+ ]
489
+ })] }),
490
+ /* @__PURE__ */ jsx(Text, { children: " " }),
491
+ /* @__PURE__ */ jsxs(Box, { children: [
492
+ /* @__PURE__ */ jsx(Text, {
493
+ dimColor: true,
494
+ children: "model:".padEnd(11)
495
+ }),
496
+ /* @__PURE__ */ jsxs(Text, { children: [model, " "] }),
497
+ /* @__PURE__ */ jsx(Text, {
498
+ color: "cyan",
499
+ children: "/model"
500
+ }),
501
+ /* @__PURE__ */ jsx(Text, {
502
+ dimColor: true,
503
+ children: " to switch"
504
+ })
505
+ ] }),
506
+ /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
507
+ dimColor: true,
508
+ children: "directory:".padEnd(11)
509
+ }), /* @__PURE__ */ jsx(Text, { children: directory })] })
510
+ ]
511
+ });
512
+ }
513
+ //#endregion
514
+ //#region src/components/ModelPicker.tsx
515
+ function ModelPicker({ currentModel, onSelect, onCancel }) {
516
+ const [options, setOptions] = useState([]);
517
+ const [error, setError] = useState(null);
518
+ useEffect(() => {
519
+ async function load() {
520
+ try {
521
+ setOptions((await listModels()).map((name) => ({
522
+ label: name,
523
+ value: name
524
+ })));
525
+ } catch (err) {
526
+ setError(err instanceof Error ? err.message : String(err));
527
+ }
528
+ }
529
+ load();
530
+ }, []);
531
+ useInput((_, key) => {
532
+ if (key.escape) onCancel();
533
+ });
534
+ if (error) return /* @__PURE__ */ jsxs(Text, {
535
+ color: "red",
536
+ children: ["Error loading models: ", error]
537
+ });
538
+ if (!options.length) return /* @__PURE__ */ jsx(Spinner, { label: "Loading models..." });
539
+ return /* @__PURE__ */ jsxs(Box, {
540
+ flexDirection: "column",
541
+ children: [/* @__PURE__ */ jsx(Text, {
542
+ dimColor: true,
543
+ children: "Select a model (↑↓ + Enter to confirm, Esc to cancel)"
544
+ }), /* @__PURE__ */ jsx(Select, {
545
+ options,
546
+ defaultValue: currentModel,
547
+ onChange: onSelect
548
+ })]
549
+ });
550
+ }
551
+ //#endregion
552
+ //#region src/components/App.tsx
553
+ function App() {
554
+ const [model, setModel] = useState(() => loadConfig().model);
555
+ const [picking, setPicking] = useState(false);
556
+ const [mode, setMode] = useState(NAME.SAFE);
557
+ const handleCommand = useCallback((command) => {
558
+ if (command === "/model") setPicking(true);
559
+ }, []);
560
+ const handleSelect = useCallback((selected) => {
561
+ setModel(selected);
562
+ saveConfig({ model: selected });
563
+ setPicking(false);
564
+ }, []);
565
+ const handleCancel = useCallback(() => {
566
+ setPicking(false);
567
+ }, []);
568
+ return /* @__PURE__ */ jsxs(Box, {
569
+ flexDirection: "column",
570
+ children: [
571
+ /* @__PURE__ */ jsx(Header, { model }),
572
+ picking ? /* @__PURE__ */ jsx(ModelPicker, {
573
+ currentModel: model,
574
+ onSelect: handleSelect,
575
+ onCancel: handleCancel
576
+ }) : /* @__PURE__ */ jsx(Chat, {
577
+ model,
578
+ onCommand: handleCommand,
579
+ mode,
580
+ onModeChange: setMode
581
+ }),
582
+ /* @__PURE__ */ jsx(Footer, {
583
+ mode,
584
+ onToggleMode: () => {
585
+ setMode((mode) => {
586
+ switch (mode) {
587
+ case NAME.SAFE: return NAME.AUTO;
588
+ case NAME.AUTO: return NAME.PLAN;
589
+ case NAME.PLAN:
590
+ default: return NAME.SAFE;
591
+ }
592
+ });
593
+ }
594
+ })
595
+ ]
596
+ });
597
+ }
598
+ //#endregion
599
+ //#region src/tui.tsx
600
+ function renderApp() {
601
+ render(/* @__PURE__ */ jsx(App, {}));
602
+ }
603
+ //#endregion
604
+ 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.0";
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-CccSOcSC.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.0",
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",
@@ -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 };