code-ollama 0.2.0 → 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
@@ -6,9 +6,29 @@ import { homedir } from "node:os";
6
6
  import { Ollama } from "ollama";
7
7
  import { exec } from "node:child_process";
8
8
  import { promisify } from "node:util";
9
+ var NAMES = [{
10
+ name: "/model",
11
+ description: "switch the model"
12
+ }].map(({ name }) => name);
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
+ };
9
29
  //#endregion
10
30
  //#region src/constants/package.ts
11
- var VERSION = "0.2.0";
31
+ var VERSION = "0.3.0";
12
32
  //#endregion
13
33
  //#region src/constants/prompt.ts
14
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
@@ -35,6 +55,19 @@ Always use tools when you need to:
35
55
  - Make file changes
36
56
  - Explore project structure
37
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`;
38
71
  //#endregion
39
72
  //#region src/constants/role.ts
40
73
  var ROLE = {
@@ -54,6 +87,9 @@ var NAME = {
54
87
  VIEW_RANGE: "view_range"
55
88
  };
56
89
  //#endregion
90
+ //#region src/constants/ui.ts
91
+ var HEADER_PREFIX = "🦙";
92
+ //#endregion
57
93
  //#region src/utils/agents.ts
58
94
  var AGENTS_FILE = "AGENTS.md";
59
95
  function loadAgentsContent() {
@@ -234,7 +270,13 @@ var TOOLS = [
234
270
  "end"
235
271
  ])
236
272
  ];
237
- 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([
238
280
  NAME.WRITE_FILE,
239
281
  NAME.EDIT_FILE,
240
282
  NAME.RUN_SHELL
@@ -242,7 +284,11 @@ var TOOLS_REQUIRING_APPROVAL = new Set([
242
284
  /**
243
285
  * Execute a tool by name with arguments
244
286
  */
245
- 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
+ };
246
292
  switch (name) {
247
293
  case NAME.READ_FILE: return readFile(args.path);
248
294
  case NAME.WRITE_FILE: return writeFile(args.path, args.content);
@@ -473,7 +519,7 @@ async function processRunStream(messages, model) {
473
519
  }
474
520
  async function main(args = process.argv.slice(2)) {
475
521
  if (!args.length) {
476
- const { renderApp } = await import("./assets/tui-DSR1MJGd.js");
522
+ const { renderApp } = await import("./assets/tui-CccSOcSC.js");
477
523
  clear();
478
524
  renderApp();
479
525
  return;
@@ -496,4 +542,4 @@ function isEntrypoint(argv1 = process.argv[1]) {
496
542
  if (isEntrypoint()) main();
497
543
  /* v8 ignore stop */
498
544
  //#endregion
499
- export { streamChat as a, createSystemMessage as c, listModels as i, ROLE as l, main, TOOLS_REQUIRING_APPROVAL as n, loadConfig as o, executeTool as r, saveConfig as s, TOOLS as t, VERSION 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.0",
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",
@@ -41,7 +41,7 @@
41
41
  "dependencies": {
42
42
  "@inkjs/ui": "2.0.0",
43
43
  "cac": "7.0.0",
44
- "ink": "7.0.1",
44
+ "ink": "7.0.2",
45
45
  "ollama": "0.6.3",
46
46
  "react": "19.2.5"
47
47
  },
@@ -61,7 +61,7 @@
61
61
  "ink-testing-library": "4.0.0",
62
62
  "lint-staged": "16.4.0",
63
63
  "prettier": "3.8.3",
64
- "publint": "0.3.18",
64
+ "publint": "0.3.19",
65
65
  "tsx": "4.21.0",
66
66
  "typescript": "6.0.3",
67
67
  "typescript-eslint": "8.59.2",
@@ -1,438 +0,0 @@
1
- import { a as streamChat, c as createSystemMessage, i as listModels, l as ROLE, n as TOOLS_REQUIRING_APPROVAL, o as loadConfig, r as executeTool, s as saveConfig, t as TOOLS, u as VERSION } 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/constants/commands.ts
8
- var COMMANDS = [{
9
- name: "/model",
10
- description: "switch the model"
11
- }];
12
- //#endregion
13
- //#region src/constants/ui.ts
14
- var HEADER_PREFIX = "🦙";
15
- //#endregion
16
- //#region src/components/Autocomplete.tsx
17
- function getMatches(input) {
18
- if (!input.startsWith("/")) return [];
19
- return COMMANDS.filter((command) => command.name.startsWith(input));
20
- }
21
- function Autocomplete({ isDisabled = false, onSubmit }) {
22
- const [value, setValue] = useState("");
23
- const [selectedIndex, setSelectedIndex] = useState(0);
24
- const [inputKey, setInputKey] = useState(0);
25
- const matches = getMatches(value);
26
- const isCommandMode = value.startsWith("/");
27
- useInput((_char, key) => {
28
- // v8 ignore next
29
- if (!isCommandMode) return;
30
- if (key.upArrow) {
31
- setSelectedIndex((i) => Math.max(0, i - 1));
32
- return;
33
- }
34
- if (key.downArrow) {
35
- setSelectedIndex((i) => Math.min(matches.length - 1, i + 1));
36
- return;
37
- }
38
- if (key.tab && matches.length > 0) {
39
- setValue((matches[selectedIndex] ?? matches[0]).name);
40
- setSelectedIndex(0);
41
- setInputKey((key) => key + 1);
42
- return;
43
- }
44
- }, { isActive: !isDisabled && isCommandMode });
45
- const handleSubmit = useCallback((input) => {
46
- const trimmed = (isCommandMode && matches.length > 0 && matches[selectedIndex] ? matches[selectedIndex].name : input).trim();
47
- if (trimmed) {
48
- onSubmit(trimmed);
49
- setValue("");
50
- setSelectedIndex(0);
51
- setInputKey((key) => key + 1);
52
- }
53
- }, [
54
- isCommandMode,
55
- matches,
56
- onSubmit,
57
- selectedIndex
58
- ]);
59
- return /* @__PURE__ */ jsxs(Box, {
60
- flexDirection: "column",
61
- children: [/* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, { children: "> " }), /* @__PURE__ */ jsx(TextInput, {
62
- isDisabled,
63
- defaultValue: value,
64
- onChange: setValue,
65
- onSubmit: handleSubmit
66
- }, inputKey)] }), isCommandMode && matches.length > 0 && /* @__PURE__ */ jsx(Box, {
67
- flexDirection: "column",
68
- marginLeft: 2,
69
- children: matches.map((command, index) => {
70
- const isHighlighted = index === selectedIndex;
71
- return /* @__PURE__ */ jsxs(Box, {
72
- gap: 3,
73
- children: [/* @__PURE__ */ jsx(Text, {
74
- color: isHighlighted ? "cyan" : void 0,
75
- bold: isHighlighted,
76
- children: command.name
77
- }), /* @__PURE__ */ jsx(Text, {
78
- dimColor: true,
79
- children: command.description
80
- })]
81
- }, command.name);
82
- })
83
- })]
84
- });
85
- }
86
- //#endregion
87
- //#region src/components/Messages.tsx
88
- function getMessageColor(role) {
89
- switch (role) {
90
- case ROLE.USER: return "black";
91
- case ROLE.ASSISTANT: return "blue";
92
- case ROLE.SYSTEM: return "gray";
93
- default: return;
94
- }
95
- }
96
- function Messages({ messages, isLoading }) {
97
- return /* @__PURE__ */ jsxs(Box, {
98
- flexDirection: "column",
99
- children: [messages.map((message, index) => /* @__PURE__ */ jsx(Box, {
100
- marginBottom: 1,
101
- children: /* @__PURE__ */ jsxs(Text, {
102
- color: getMessageColor(message.role),
103
- dimColor: message.role === ROLE.SYSTEM,
104
- children: [message.role === ROLE.USER ? "> " : "", message.content]
105
- })
106
- }, index)), isLoading && messages[messages.length - 1]?.content === "" && /* @__PURE__ */ jsx(Box, {
107
- marginTop: -1,
108
- marginBottom: 1,
109
- children: /* @__PURE__ */ jsx(Spinner, { label: "Thinking..." })
110
- })]
111
- });
112
- }
113
- //#endregion
114
- //#region src/components/ToolApproval.tsx
115
- function ToolApproval({ toolCall, onApprove, onReject }) {
116
- const [selected, setSelected] = useState("yes");
117
- useInput((_, key) => {
118
- if (key.return) if (selected === "yes") onApprove();
119
- else onReject();
120
- else if (key.leftArrow || key.rightArrow) setSelected((prev) => prev === "yes" ? "no" : "yes");
121
- // v8 ignore stop
122
- });
123
- const args = JSON.stringify(toolCall.function.arguments, null, 2);
124
- return /* @__PURE__ */ jsxs(Box, {
125
- flexDirection: "column",
126
- marginY: 1,
127
- children: [
128
- /* @__PURE__ */ jsx(Text, {
129
- color: "yellow",
130
- bold: true,
131
- children: "⚠️ Tool requires approval:"
132
- }),
133
- /* @__PURE__ */ jsxs(Box, {
134
- marginX: 2,
135
- flexDirection: "column",
136
- children: [/* @__PURE__ */ jsxs(Text, { children: [
137
- /* @__PURE__ */ jsx(Text, {
138
- bold: true,
139
- children: "Tool:"
140
- }),
141
- " ",
142
- toolCall.function.name
143
- ] }), /* @__PURE__ */ jsxs(Text, { children: [
144
- /* @__PURE__ */ jsx(Text, {
145
- bold: true,
146
- children: "Arguments:"
147
- }),
148
- " ",
149
- args
150
- ] })]
151
- }),
152
- /* @__PURE__ */ jsxs(Box, {
153
- marginTop: 1,
154
- gap: 2,
155
- children: [/* @__PURE__ */ jsx(Text, { children: /* @__PURE__ */ jsxs(Text, {
156
- color: selected === "yes" ? "green" : void 0,
157
- children: [selected === "yes" ? "▶ " : " ", "✓ Yes (Enter)"]
158
- }) }), /* @__PURE__ */ jsx(Text, { children: /* @__PURE__ */ jsxs(Text, {
159
- color: selected === "no" ? "red" : void 0,
160
- children: [selected === "no" ? "▶ " : " ", "✗ No (Esc)"]
161
- }) })]
162
- })
163
- ]
164
- });
165
- }
166
- //#endregion
167
- //#region src/components/Chat.tsx
168
- function Chat({ model, onCommand, autoExecute }) {
169
- const [messages, setMessages] = useState([createSystemMessage()]);
170
- const [submitKey, setSubmitKey] = useState(0);
171
- const [isLoading, setIsLoading] = useState(false);
172
- const [pendingToolCall, setPendingToolCall] = useState(null);
173
- const processStream = useCallback(async (currentMessages) => {
174
- const assistantMessage = {
175
- role: ROLE.ASSISTANT,
176
- content: ""
177
- };
178
- setMessages((previousMessages) => [...previousMessages, assistantMessage]);
179
- try {
180
- for await (const chunk of streamChat(currentMessages, model, TOOLS)) if (chunk.type === "content") {
181
- assistantMessage.content += chunk.content;
182
- setMessages((previousMessages) => {
183
- const newMessages = [...previousMessages];
184
- newMessages[newMessages.length - 1] = { ...assistantMessage };
185
- return newMessages;
186
- });
187
- } else if (chunk.type === "tool_calls") for (const toolCall of chunk.tool_calls) {
188
- const requiresApproval = TOOLS_REQUIRING_APPROVAL.has(toolCall.function.name);
189
- if (!autoExecute && requiresApproval) {
190
- setPendingToolCall(toolCall);
191
- setIsLoading(false);
192
- return;
193
- }
194
- const result = await executeTool(toolCall.function.name, toolCall.function.arguments);
195
- const toolResultMessage = {
196
- role: ROLE.SYSTEM,
197
- content: `Tool ${toolCall.function.name} result:\n${result.content}${result.error ? `\nError: ${result.error}` : ""}`
198
- };
199
- const newMessages = [
200
- ...currentMessages,
201
- assistantMessage,
202
- toolResultMessage
203
- ];
204
- setMessages((previousMessages) => [...previousMessages, toolResultMessage]);
205
- await processStream(newMessages);
206
- return;
207
- }
208
- } catch (error) {
209
- assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`;
210
- setMessages((previousMessages) => {
211
- const newMessages = [...previousMessages];
212
- newMessages[newMessages.length - 1] = { ...assistantMessage };
213
- return newMessages;
214
- });
215
- } finally {
216
- setIsLoading(false);
217
- }
218
- }, [model, autoExecute]);
219
- const handleToolApproval = useCallback(async (approved) => {
220
- // v8 ignore next
221
- if (!pendingToolCall) return;
222
- const toolCall = pendingToolCall;
223
- setPendingToolCall(null);
224
- setIsLoading(true);
225
- if (approved) {
226
- const result = await executeTool(toolCall.function.name, toolCall.function.arguments);
227
- const toolResultMessage = {
228
- role: ROLE.SYSTEM,
229
- content: `Tool ${toolCall.function.name} result:\n${result.content}${result.error ? `\nError: ${result.error}` : ""}`
230
- };
231
- const newMessages = [...messages, toolResultMessage];
232
- setMessages((previousMessages) => [...previousMessages, toolResultMessage]);
233
- await processStream(newMessages);
234
- } else {
235
- const rejectionMessage = {
236
- role: ROLE.SYSTEM,
237
- content: `User declined to execute tool ${toolCall.function.name}`
238
- };
239
- const newMessages = [...messages, rejectionMessage];
240
- setMessages((previousMessages) => [...previousMessages, rejectionMessage]);
241
- await processStream(newMessages);
242
- }
243
- }, [
244
- pendingToolCall,
245
- messages,
246
- processStream
247
- ]);
248
- const handleSubmit = useCallback(async (value) => {
249
- const userContent = value.trim();
250
- if (!userContent) return;
251
- setSubmitKey((key) => key + 1);
252
- if (userContent.startsWith("/")) {
253
- onCommand(userContent);
254
- return;
255
- }
256
- setIsLoading(true);
257
- const userMessage = {
258
- role: ROLE.USER,
259
- content: userContent
260
- };
261
- setMessages((previousMessages) => [...previousMessages, userMessage]);
262
- await processStream([...messages, userMessage]);
263
- }, [
264
- messages,
265
- onCommand,
266
- processStream
267
- ]);
268
- return /* @__PURE__ */ jsxs(Box, {
269
- flexDirection: "column",
270
- children: [
271
- /* @__PURE__ */ jsx(Messages, {
272
- messages: messages.slice(1),
273
- isLoading
274
- }),
275
- pendingToolCall && /* @__PURE__ */ jsx(ToolApproval, {
276
- toolCall: pendingToolCall,
277
- onApprove: () => void handleToolApproval(true),
278
- onReject: () => void handleToolApproval(false)
279
- }),
280
- !pendingToolCall && /* @__PURE__ */ jsx(Autocomplete, {
281
- isDisabled: isLoading,
282
- onSubmit: (val) => {
283
- handleSubmit(val);
284
- }
285
- }, submitKey)
286
- ]
287
- });
288
- }
289
- //#endregion
290
- //#region src/components/Footer.tsx
291
- function Footer({ autoExecute, onToggleMode }) {
292
- useInput((_, key) => {
293
- if (key.tab && key.shift) onToggleMode();
294
- });
295
- return /* @__PURE__ */ jsx(Box, {
296
- justifyContent: "space-between",
297
- marginTop: 1,
298
- children: /* @__PURE__ */ jsxs(Text, {
299
- dimColor: true,
300
- children: [
301
- "Mode: ",
302
- autoExecute ? "Auto" : "Safe",
303
- " (Shift+Tab to toggle)"
304
- ]
305
- })
306
- });
307
- }
308
- //#endregion
309
- //#region src/components/Header.tsx
310
- function abbreviatePath(dir) {
311
- const home = homedir();
312
- return dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir;
313
- }
314
- function Header({ model }) {
315
- const directory = abbreviatePath(process.cwd());
316
- return /* @__PURE__ */ jsxs(Box, {
317
- borderStyle: "round",
318
- flexDirection: "column",
319
- paddingX: 1,
320
- children: [
321
- /* @__PURE__ */ jsxs(Text, { children: [/* @__PURE__ */ jsxs(Text, {
322
- bold: true,
323
- children: [HEADER_PREFIX, "Code Ollama"]
324
- }), /* @__PURE__ */ jsxs(Text, {
325
- dimColor: true,
326
- children: [
327
- " (v",
328
- VERSION,
329
- ")"
330
- ]
331
- })] }),
332
- /* @__PURE__ */ jsx(Text, { children: " " }),
333
- /* @__PURE__ */ jsxs(Box, { children: [
334
- /* @__PURE__ */ jsx(Text, {
335
- dimColor: true,
336
- children: "model:".padEnd(11)
337
- }),
338
- /* @__PURE__ */ jsxs(Text, { children: [model, " "] }),
339
- /* @__PURE__ */ jsx(Text, {
340
- color: "cyan",
341
- children: "/model"
342
- }),
343
- /* @__PURE__ */ jsx(Text, {
344
- dimColor: true,
345
- children: " to switch"
346
- })
347
- ] }),
348
- /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
349
- dimColor: true,
350
- children: "directory:".padEnd(11)
351
- }), /* @__PURE__ */ jsx(Text, { children: directory })] })
352
- ]
353
- });
354
- }
355
- //#endregion
356
- //#region src/components/ModelPicker.tsx
357
- function ModelPicker({ currentModel, onSelect, onCancel }) {
358
- const [options, setOptions] = useState([]);
359
- const [error, setError] = useState(null);
360
- useEffect(() => {
361
- async function load() {
362
- try {
363
- setOptions((await listModels()).map((name) => ({
364
- label: name,
365
- value: name
366
- })));
367
- } catch (err) {
368
- setError(err instanceof Error ? err.message : String(err));
369
- }
370
- }
371
- load();
372
- }, []);
373
- useInput((_, key) => {
374
- if (key.escape) onCancel();
375
- });
376
- if (error) return /* @__PURE__ */ jsxs(Text, {
377
- color: "red",
378
- children: ["Error loading models: ", error]
379
- });
380
- if (!options.length) return /* @__PURE__ */ jsx(Spinner, { label: "Loading models..." });
381
- return /* @__PURE__ */ jsxs(Box, {
382
- flexDirection: "column",
383
- children: [/* @__PURE__ */ jsx(Text, {
384
- dimColor: true,
385
- children: "Select a model (↑↓ + Enter to confirm, Esc to cancel)"
386
- }), /* @__PURE__ */ jsx(Select, {
387
- options,
388
- defaultValue: currentModel,
389
- onChange: onSelect
390
- })]
391
- });
392
- }
393
- //#endregion
394
- //#region src/components/App.tsx
395
- function App() {
396
- const [model, setModel] = useState(() => loadConfig().model);
397
- const [picking, setPicking] = useState(false);
398
- const [autoExecute, setAutoExecute] = useState(false);
399
- const handleCommand = useCallback((command) => {
400
- if (command === "/model") setPicking(true);
401
- }, []);
402
- const handleSelect = useCallback((selected) => {
403
- setModel(selected);
404
- saveConfig({ model: selected });
405
- setPicking(false);
406
- }, []);
407
- const handleCancel = useCallback(() => {
408
- setPicking(false);
409
- }, []);
410
- return /* @__PURE__ */ jsxs(Box, {
411
- flexDirection: "column",
412
- children: [
413
- /* @__PURE__ */ jsx(Header, { model }),
414
- picking ? /* @__PURE__ */ jsx(ModelPicker, {
415
- currentModel: model,
416
- onSelect: handleSelect,
417
- onCancel: handleCancel
418
- }) : /* @__PURE__ */ jsx(Chat, {
419
- model,
420
- onCommand: handleCommand,
421
- autoExecute
422
- }),
423
- /* @__PURE__ */ jsx(Footer, {
424
- autoExecute,
425
- onToggleMode: () => {
426
- setAutoExecute((isAutoExecute) => !isAutoExecute);
427
- }
428
- })
429
- ]
430
- });
431
- }
432
- //#endregion
433
- //#region src/tui.tsx
434
- function renderApp() {
435
- render(/* @__PURE__ */ jsx(App, {}));
436
- }
437
- //#endregion
438
- export { renderApp };