code-ollama 0.7.0 → 0.9.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.
@@ -5,8 +5,10 @@ import { homedir } from "node:os";
5
5
  import { exec } from "node:child_process";
6
6
  import { Box, Text, render, useApp, useInput } from "ink";
7
7
  import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
8
- import { Select, Spinner, TextInput } from "@inkjs/ui";
8
+ import { Select, Spinner } from "@inkjs/ui";
9
9
  import { jsx, jsxs } from "react/jsx-runtime";
10
+ import { marked } from "marked";
11
+ import TerminalRenderer from "marked-terminal";
10
12
  //#region src/constants/command.ts
11
13
  var LIST = [
12
14
  {
@@ -40,24 +42,173 @@ var LABEL = {
40
42
  };
41
43
  //#endregion
42
44
  //#region src/constants/ui.ts
43
- var HEADER_PREFIX = "🦙";
45
+ var HEADER_PREFIX = "🦙 ";
44
46
  //#endregion
45
- //#region src/components/Messages.tsx
47
+ //#region src/components/CodeBlock/CodeBlock.tsx
48
+ async function highlightCode(code, language = "text") {
49
+ const { codeToANSI } = await import("@shikijs/cli");
50
+ try {
51
+ return await codeToANSI(code, language, "github-light");
52
+ } catch {
53
+ // v8 ignore next - Defensive fallback for unsupported languages
54
+ return code;
55
+ }
56
+ }
57
+ var CodeBlock = memo(function CodeBlock({ code, language, role }) {
58
+ const [highlighted, setHighlighted] = useState(code);
59
+ useEffect(() => {
60
+ let canceled = false;
61
+ async function loadHighlight() {
62
+ try {
63
+ const result = await highlightCode(code, language);
64
+ if (!canceled) setHighlighted(result);
65
+ } catch {}
66
+ }
67
+ loadHighlight();
68
+ return () => {
69
+ canceled = true;
70
+ };
71
+ }, [code, language]);
72
+ const isSystem = role === ROLE.SYSTEM;
73
+ return /* @__PURE__ */ jsx(Box, {
74
+ flexDirection: "column",
75
+ borderStyle: "round",
76
+ borderColor: isSystem ? "gray" : "dim",
77
+ paddingX: 1,
78
+ marginY: 1,
79
+ children: /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Text, {
80
+ dimColor: isSystem,
81
+ children: highlighted
82
+ }) })
83
+ });
84
+ });
85
+ //#endregion
86
+ //#region src/components/Markdown/Markdown.tsx
87
+ marked.setOptions({ renderer: new TerminalRenderer({ theme: "gitHub" }) });
88
+ function renderMarkdown(content) {
89
+ const result = marked.parse(content);
90
+ // v8 ignore next - Defensive fallback for Promise return
91
+ return typeof result === "string" ? result.trim() : "";
92
+ }
93
+ var Markdown = memo(function Markdown({ content, color, dimColor }) {
94
+ const [rendered, setRendered] = useState(content);
95
+ useEffect(() => {
96
+ let canceled = false;
97
+ function loadMarkdown() {
98
+ try {
99
+ const result = renderMarkdown(content);
100
+ // v8 ignore start
101
+ if (!canceled) setRendered(result);
102
+ } catch {}
103
+ // v8 ignore stop
104
+ }
105
+ loadMarkdown();
106
+ return () => {
107
+ canceled = true;
108
+ };
109
+ }, [content]);
110
+ return /* @__PURE__ */ jsx(Text, {
111
+ color,
112
+ dimColor,
113
+ children: rendered
114
+ });
115
+ });
116
+ //#endregion
117
+ //#region src/components/Messages/constants.ts
118
+ var TURN_ABORTED_MESSAGE = [
119
+ "<turn_aborted>",
120
+ "The user interrupted the previous turn on purpose. Any running commands may still be running in the background. If any tools were aborted, they may have partially executed.",
121
+ "</turn_aborted>"
122
+ ].join("\n");
123
+ //#endregion
124
+ //#region src/components/Messages/Messages.tsx
46
125
  function getMessageColor(role) {
47
126
  switch (role) {
48
127
  case ROLE.USER: return "black";
49
- case ROLE.ASSISTANT: return "blue";
128
+ case ROLE.ASSISTANT: return "cyan";
50
129
  case ROLE.SYSTEM: return "gray";
51
130
  default: return;
52
131
  }
53
132
  }
54
- var MessageRow = memo(function MessageRow({ message }) {
133
+ function parseContent(content) {
134
+ const segments = [];
135
+ const codeBlockRegex = /```(\w+)?\n?([\s\S]*?)```/g;
136
+ let lastIndex = 0;
137
+ let match;
138
+ while ((match = codeBlockRegex.exec(content)) !== null) {
139
+ if (match.index > lastIndex) {
140
+ const textContent = content.slice(lastIndex, match.index).trim();
141
+ // v8 ignore next 2 - Defensive check for empty trimmed content
142
+ if (textContent) segments.push({
143
+ type: "text",
144
+ content: textContent
145
+ });
146
+ }
147
+ const language = match[1];
148
+ const codeContent = match[2].trim();
149
+ // v8 ignore next 2 - Defensive check for empty code block
150
+ if (codeContent) segments.push({
151
+ type: "code",
152
+ content: codeContent,
153
+ language
154
+ });
155
+ lastIndex = match.index + match[0].length;
156
+ }
157
+ if (lastIndex < content.length) {
158
+ const textContent = content.slice(lastIndex).trim();
159
+ // v8 ignore next 2 - Defensive check for empty trimmed content
160
+ if (textContent) segments.push({
161
+ type: "text",
162
+ content: textContent
163
+ });
164
+ }
165
+ // v8 ignore next 2 - Defensive fallback for edge case
166
+ if (segments.length === 0 && content.trim()) segments.push({
167
+ type: "text",
168
+ content: content.trim()
169
+ });
170
+ return segments;
171
+ }
172
+ var Message = memo(function Message({ message }) {
173
+ const messageColor = getMessageColor(message.role);
174
+ const isSystem = message.role === ROLE.SYSTEM;
175
+ const isUser = message.role === ROLE.USER;
176
+ if (isSystem) return /* @__PURE__ */ jsx(Box, {
177
+ flexDirection: "column",
178
+ marginBottom: 1,
179
+ marginX: 2,
180
+ children: /* @__PURE__ */ jsx(Text, {
181
+ color: messageColor,
182
+ dimColor: true,
183
+ children: message.content
184
+ })
185
+ });
55
186
  return /* @__PURE__ */ jsx(Box, {
187
+ flexDirection: "column",
56
188
  marginBottom: 1,
57
- children: /* @__PURE__ */ jsxs(Text, {
58
- color: getMessageColor(message.role),
59
- dimColor: message.role === ROLE.SYSTEM,
60
- children: [message.role === ROLE.USER ? "> " : "", message.content]
189
+ children: parseContent(message.content).map((segment, index) => {
190
+ const prefix = isUser && index === 0 ? "> " : "";
191
+ if (segment.type === "code") return isUser ? /* @__PURE__ */ jsx(Text, {
192
+ color: messageColor,
193
+ children: segment.content
194
+ }, index) : /* @__PURE__ */ jsx(Box, {
195
+ marginX: 2,
196
+ children: /* @__PURE__ */ jsx(CodeBlock, {
197
+ code: segment.content,
198
+ language: segment.language,
199
+ role: message.role
200
+ })
201
+ }, index);
202
+ return isUser ? /* @__PURE__ */ jsx(Text, {
203
+ color: messageColor,
204
+ children: prefix + segment.content
205
+ }, index) : /* @__PURE__ */ jsx(Box, {
206
+ marginX: 2,
207
+ children: /* @__PURE__ */ jsx(Markdown, {
208
+ content: segment.content,
209
+ color: messageColor
210
+ })
211
+ }, index);
61
212
  })
62
213
  });
63
214
  });
@@ -65,11 +216,12 @@ function Messages({ messages, isLoading, streamingMessage }) {
65
216
  return /* @__PURE__ */ jsxs(Box, {
66
217
  flexDirection: "column",
67
218
  children: [
68
- messages.map((message, index) => /* @__PURE__ */ jsx(MessageRow, { message }, `${String(index)}-${message.role}-${message.content.slice(0, 16)}`)),
69
- streamingMessage && /* @__PURE__ */ jsx(MessageRow, { message: streamingMessage }),
219
+ messages.filter(({ content }) => content !== TURN_ABORTED_MESSAGE).map((message, index) => /* @__PURE__ */ jsx(Message, { message }, index)),
220
+ streamingMessage && /* @__PURE__ */ jsx(Message, { message: streamingMessage }),
70
221
  isLoading && !streamingMessage?.content && /* @__PURE__ */ jsx(Box, {
71
222
  marginTop: -1,
72
223
  marginBottom: 1,
224
+ marginX: 2,
73
225
  children: /* @__PURE__ */ jsx(Spinner, { label: "Thinking..." })
74
226
  })
75
227
  ]
@@ -226,6 +378,76 @@ function ToolApproval({ toolCall, onDecision }) {
226
378
  var ACTION_NOT_PERFORMED = "The requested action was NOT performed";
227
379
  var PLAN_CHECKLIST_REMINDER = "Then display the execution plan as an unchecked Markdown checklist only";
228
380
  var PLAN_EXECUTION_REMINDER = "Do not claim success and do not call write_file or run_shell until the user approves execution";
381
+ var INTERRUPT_REASON = /* @__PURE__ */ function(INTERRUPT_REASON) {
382
+ INTERRUPT_REASON["INTERRUPTED"] = "interrupted";
383
+ INTERRUPT_REASON["REJECTED"] = "rejected";
384
+ return INTERRUPT_REASON;
385
+ }({});
386
+ //#endregion
387
+ //#region src/components/TextInput/TextInput.tsx
388
+ function TextInput({ value, isDisabled = false, placeholder, onChange, onSubmit }) {
389
+ const [cursorPosition, setCursorPosition] = useState(value.length);
390
+ const prevValueRef = useRef(value);
391
+ useEffect(() => {
392
+ const prevValue = prevValueRef.current;
393
+ prevValueRef.current = value;
394
+ if (value === "") setCursorPosition(0);
395
+ else if (value.length > prevValue.length && cursorPosition <= prevValue.length) setCursorPosition(value.length);
396
+ else if (cursorPosition > value.length) setCursorPosition(value.length);
397
+ }, [value, cursorPosition]);
398
+ useInput((input, key) => {
399
+ // v8 ignore next
400
+ if (isDisabled) return;
401
+ if (key.return) {
402
+ onSubmit(value);
403
+ setCursorPosition(0);
404
+ return;
405
+ }
406
+ if (key.backspace) {
407
+ if (cursorPosition > 0) {
408
+ onChange(value.slice(0, cursorPosition - 1) + value.slice(cursorPosition));
409
+ setCursorPosition(cursorPosition - 1);
410
+ }
411
+ return;
412
+ }
413
+ // v8 ignore start
414
+ if (key.delete) {
415
+ if (cursorPosition < value.length) onChange(value.slice(0, cursorPosition) + value.slice(cursorPosition + 1));
416
+ return;
417
+ }
418
+ // v8 ignore stop
419
+ if (key.leftArrow) {
420
+ setCursorPosition(Math.max(0, cursorPosition - 1));
421
+ return;
422
+ }
423
+ if (key.rightArrow) {
424
+ setCursorPosition(Math.min(value.length, cursorPosition + 1));
425
+ return;
426
+ }
427
+ if (key.home) {
428
+ setCursorPosition(0);
429
+ return;
430
+ }
431
+ if (key.end) {
432
+ setCursorPosition(value.length);
433
+ return;
434
+ }
435
+ // v8 ignore start
436
+ if (input) {
437
+ onChange(value.slice(0, cursorPosition) + input + value.slice(cursorPosition));
438
+ setCursorPosition(cursorPosition + input.length);
439
+ }
440
+ // v8 ignore stop
441
+ }, { isActive: !isDisabled });
442
+ const displayValue = value || (placeholder ?? "");
443
+ const isPlaceholder = Boolean(!value && placeholder);
444
+ const char = displayValue[cursorPosition] || " ";
445
+ const before = displayValue.slice(0, cursorPosition);
446
+ const after = displayValue.slice(cursorPosition + 1);
447
+ const dimStyle = isPlaceholder ? "\x1B[2m" : "";
448
+ const resetDim = isPlaceholder ? "\x1B[22m" : "";
449
+ return /* @__PURE__ */ jsx(Text, { children: `${dimStyle}${before}${resetDim}\x1b[7m${char}\x1b[27m${dimStyle}${after}${resetDim}` });
450
+ }
229
451
  //#endregion
230
452
  //#region src/components/Chat/CommandMenu.tsx
231
453
  function getMatchingCommands(input) {
@@ -376,18 +598,16 @@ function FileSuggestions({ input, isDisabled = false, onChange, onSelect }) {
376
598
  function hasFileSuggestionQuery(input) {
377
599
  return /(^|\s)@\S+$/.test(input);
378
600
  }
379
- function Input({ isDisabled = false, onSubmit }) {
601
+ function Input({ isDisabled = false, onInterrupt, onSubmit }) {
380
602
  const { exit } = useApp();
381
603
  const [input, setInput] = useState("");
382
- const [inputKey, setInputKey] = useState(0);
383
604
  const fileSuggestionRef = useRef(null);
384
- const remountTextInput = useCallback(() => {
385
- setInputKey((key) => key + 1);
386
- }, [setInputKey]);
605
+ const resetInput = useCallback(() => {
606
+ setInput("");
607
+ }, []);
387
608
  const handleSelectFileSuggestion = useCallback((nextInput) => {
388
609
  setInput(nextInput);
389
- remountTextInput();
390
- }, [remountTextInput]);
610
+ }, []);
391
611
  const handleFileSuggestionChange = useCallback((nextInput) => {
392
612
  fileSuggestionRef.current = nextInput;
393
613
  }, []);
@@ -395,10 +615,9 @@ function Input({ isDisabled = false, onSubmit }) {
395
615
  const trimmedInput = input.trim();
396
616
  if (!trimmedInput) return;
397
617
  onSubmit(trimmedInput);
398
- setInput("");
618
+ resetInput();
399
619
  fileSuggestionRef.current = null;
400
- remountTextInput();
401
- }, [onSubmit, remountTextInput]);
620
+ }, [onSubmit, resetInput]);
402
621
  const showCommandMenu = input.startsWith("/");
403
622
  const showFileSuggestions = !showCommandMenu && hasFileSuggestionQuery(input);
404
623
  const handleSubmitText = useCallback(async (input) => {
@@ -414,21 +633,29 @@ function Input({ isDisabled = false, onSubmit }) {
414
633
  if (LIST.find(({ name }) => name === input)) submitAndReset(input);
415
634
  }, [submitAndReset]);
416
635
  useInput((_input, key) => {
417
- if (key.ctrl && _input === "c") if (input) {
418
- setInput("");
419
- remountTextInput();
420
- } else exit();
636
+ const isCtrlC = key.ctrl && _input === "c";
637
+ if (isDisabled) {
638
+ if (key.escape || isCtrlC) onInterrupt?.();
639
+ return;
640
+ }
641
+ if (isCtrlC) {
642
+ if (input) {
643
+ resetInput();
644
+ return;
645
+ }
646
+ exit();
647
+ }
421
648
  });
422
649
  return /* @__PURE__ */ jsxs(Box, {
423
650
  flexDirection: "column",
424
651
  children: [
425
652
  /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, { children: "> " }), /* @__PURE__ */ jsx(TextInput, {
426
- defaultValue: input,
653
+ value: input,
427
654
  isDisabled,
428
655
  onChange: setInput,
429
656
  onSubmit: handleSubmitText,
430
657
  placeholder: "Ask anything... (/ commands, @ files)"
431
- }, inputKey)] }),
658
+ })] }),
432
659
  showCommandMenu && /* @__PURE__ */ jsx(CommandMenu, {
433
660
  input,
434
661
  onSubmit: handleSubmitCommand
@@ -458,12 +685,15 @@ function Chat({ model, onCommand, mode, onModeChange, sessionId }) {
458
685
  const [isLoading, setIsLoading] = useState(false);
459
686
  const [pendingToolCall, setPendingToolCall] = useState(null);
460
687
  const [pendingPlan, setPendingPlan] = useState(null);
688
+ const [interruptReason, setInterruptReason] = useState(null);
689
+ const abortControllerRef = useRef(null);
461
690
  useEffect(() => {
462
691
  setMessages([]);
463
692
  setStreamingMessage(null);
464
693
  setIsLoading(false);
465
694
  setPendingToolCall(null);
466
695
  setPendingPlan(null);
696
+ setInterruptReason(null);
467
697
  }, [sessionId]);
468
698
  const buildToolResultMessage = useCallback((toolName, result) => {
469
699
  if (result.error?.startsWith("Tool not allowed:")) return {
@@ -490,7 +720,20 @@ function Chat({ model, onCommand, mode, onModeChange, sessionId }) {
490
720
  PLAN_EXECUTION_REMINDER
491
721
  ].join("\n")
492
722
  }), []);
723
+ const handleInterrupt = useCallback(() => {
724
+ abortControllerRef.current?.abort();
725
+ abortControllerRef.current = null;
726
+ setIsLoading(false);
727
+ setStreamingMessage(null);
728
+ setInterruptReason(INTERRUPT_REASON.INTERRUPTED);
729
+ setMessages((prev) => [...prev, {
730
+ role: ROLE.USER,
731
+ content: TURN_ABORTED_MESSAGE
732
+ }]);
733
+ }, []);
493
734
  const processStream = useCallback(async (currentMessages, executionMode = mode) => {
735
+ const controller = new AbortController();
736
+ abortControllerRef.current = controller;
494
737
  const assistantMessage = {
495
738
  role: ROLE.ASSISTANT,
496
739
  content: ""
@@ -518,33 +761,40 @@ function Chat({ model, onCommand, mode, onModeChange, sessionId }) {
518
761
  };
519
762
  setStreamingMessage(assistantMessage);
520
763
  try {
521
- for await (const chunk of streamChat(withSystemMessage(currentMessages), model, TOOLS)) if (chunk.type === "content") {
522
- assistantMessage.content += chunk.content;
523
- setStreamingMessage({ ...assistantMessage });
524
- } else if (chunk.type === "tool_calls") for (const toolCall of chunk.tool_calls) {
525
- const requiresApproval = WRITE_TOOLS.has(toolCall.function.name);
526
- // v8 ignore start
527
- const allowedTools = executionMode === NAME.PLAN ? READ_TOOLS : void 0;
528
- // v8 ignore stop
529
- const updatedMessages = commitAssistantMessage();
530
- if (executionMode === NAME.SAFE && requiresApproval) {
531
- setPendingToolCall(toolCall);
532
- setIsLoading(false);
764
+ for await (const chunk of streamChat(withSystemMessage(currentMessages), model, TOOLS, controller.signal)) {
765
+ // v8 ignore next 3
766
+ if (controller.signal.aborted) return;
767
+ if (chunk.type === "content") {
768
+ assistantMessage.content += chunk.content;
769
+ setStreamingMessage({ ...assistantMessage });
770
+ } else if (chunk.type === "tool_calls") for (const toolCall of chunk.tool_calls) {
771
+ const requiresApproval = WRITE_TOOLS.has(toolCall.function.name);
772
+ // v8 ignore start
773
+ const allowedTools = executionMode === NAME.PLAN ? READ_TOOLS : void 0;
774
+ // v8 ignore stop
775
+ const updatedMessages = commitAssistantMessage();
776
+ if (executionMode === NAME.SAFE && requiresApproval) {
777
+ setPendingToolCall(toolCall);
778
+ setIsLoading(false);
779
+ return;
780
+ }
781
+ const result = await executeTool(toolCall.function.name, toolCall.function.arguments, { allowedTools });
782
+ const toolResultMessage = buildToolResultMessage(toolCall.function.name, result);
783
+ const newMessages = [...updatedMessages, toolResultMessage];
784
+ setMessages(newMessages);
785
+ await processStream(newMessages, executionMode);
533
786
  return;
534
787
  }
535
- const result = await executeTool(toolCall.function.name, toolCall.function.arguments, { allowedTools });
536
- const toolResultMessage = buildToolResultMessage(toolCall.function.name, result);
537
- const newMessages = [...updatedMessages, toolResultMessage];
538
- setMessages(newMessages);
539
- await processStream(newMessages, executionMode);
540
- return;
541
788
  }
542
789
  commitAssistantMessage();
543
790
  } catch (error) {
544
791
  // v8 ignore next
545
- assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`;
546
- commitAssistantMessage();
792
+ if (!controller.signal.aborted) {
793
+ assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`;
794
+ commitAssistantMessage();
795
+ }
547
796
  } finally {
797
+ if (abortControllerRef.current === controller) abortControllerRef.current = null;
548
798
  setIsLoading(false);
549
799
  }
550
800
  }, [
@@ -553,6 +803,8 @@ function Chat({ model, onCommand, mode, onModeChange, sessionId }) {
553
803
  mode
554
804
  ]);
555
805
  const processStreamReadOnly = useCallback(async (currentMessages) => {
806
+ const controller = new AbortController();
807
+ abortControllerRef.current = controller;
556
808
  const assistantMessage = {
557
809
  role: ROLE.ASSISTANT,
558
810
  content: ""
@@ -581,24 +833,28 @@ function Chat({ model, onCommand, mode, onModeChange, sessionId }) {
581
833
  setStreamingMessage(assistantMessage);
582
834
  try {
583
835
  const readOnlyTools = TOOLS.filter((tool) => READ_TOOLS.has(tool.function.name));
584
- for await (const chunk of streamChat(withSystemMessage(currentMessages), model, readOnlyTools)) if (chunk.type === "content") {
585
- assistantMessage.content += chunk.content;
586
- setStreamingMessage({ ...assistantMessage });
587
- } else if (chunk.type === "tool_calls") for (const toolCall of chunk.tool_calls) {
588
- const updatedMessages = commitAssistantMessage();
589
- if (!READ_TOOLS.has(toolCall.function.name)) {
590
- const correctionMessage = buildPlanModeCorrectionMessage(toolCall.function.name);
591
- const newMessages = [...updatedMessages, correctionMessage];
836
+ for await (const chunk of streamChat(withSystemMessage(currentMessages), model, readOnlyTools, controller.signal)) {
837
+ // v8 ignore next 3
838
+ if (controller.signal.aborted) return;
839
+ if (chunk.type === "content") {
840
+ assistantMessage.content += chunk.content;
841
+ setStreamingMessage({ ...assistantMessage });
842
+ } else if (chunk.type === "tool_calls") for (const toolCall of chunk.tool_calls) {
843
+ const updatedMessages = commitAssistantMessage();
844
+ if (!READ_TOOLS.has(toolCall.function.name)) {
845
+ const correctionMessage = buildPlanModeCorrectionMessage(toolCall.function.name);
846
+ const newMessages = [...updatedMessages, correctionMessage];
847
+ setMessages(newMessages);
848
+ await processStreamReadOnly(newMessages);
849
+ return;
850
+ }
851
+ const result = await executeTool(toolCall.function.name, toolCall.function.arguments, { allowedTools: READ_TOOLS });
852
+ const toolResultMessage = buildToolResultMessage(toolCall.function.name, result);
853
+ const newMessages = [...updatedMessages, toolResultMessage];
592
854
  setMessages(newMessages);
593
855
  await processStreamReadOnly(newMessages);
594
856
  return;
595
857
  }
596
- const result = await executeTool(toolCall.function.name, toolCall.function.arguments, { allowedTools: READ_TOOLS });
597
- const toolResultMessage = buildToolResultMessage(toolCall.function.name, result);
598
- const newMessages = [...updatedMessages, toolResultMessage];
599
- setMessages(newMessages);
600
- await processStreamReadOnly(newMessages);
601
- return;
602
858
  }
603
859
  const researchMessages = commitAssistantMessage();
604
860
  const planInstruction = {
@@ -612,9 +868,13 @@ function Chat({ model, onCommand, mode, onModeChange, sessionId }) {
612
868
  };
613
869
  setStreamingMessage(planAssistantMessage);
614
870
  try {
615
- for await (const chunk of streamChat(withSystemMessage(planMessages), model, [])) if (chunk.type === "content") {
616
- planAssistantMessage.content += chunk.content;
617
- setStreamingMessage({ ...planAssistantMessage });
871
+ for await (const chunk of streamChat(withSystemMessage(planMessages), model, [], controller.signal)) {
872
+ // v8 ignore next 3
873
+ if (controller.signal.aborted) return;
874
+ if (chunk.type === "content") {
875
+ planAssistantMessage.content += chunk.content;
876
+ setStreamingMessage({ ...planAssistantMessage });
877
+ }
618
878
  }
619
879
  } catch (error) {
620
880
  // v8 ignore next
@@ -634,9 +894,12 @@ function Chat({ model, onCommand, mode, onModeChange, sessionId }) {
634
894
  setIsLoading(false);
635
895
  } catch (error) {
636
896
  // v8 ignore next
637
- assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`;
638
- commitAssistantMessage();
897
+ if (!controller.signal.aborted) {
898
+ assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`;
899
+ commitAssistantMessage();
900
+ }
639
901
  } finally {
902
+ if (abortControllerRef.current === controller) abortControllerRef.current = null;
640
903
  setIsLoading(false);
641
904
  }
642
905
  }, [
@@ -689,16 +952,14 @@ function Chat({ model, onCommand, mode, onModeChange, sessionId }) {
689
952
  await processStream(newMessages);
690
953
  break;
691
954
  }
692
- case REJECT: {
693
- const rejectionMessage = {
694
- role: ROLE.SYSTEM,
695
- content: `User declined to execute tool ${toolCall.function.name}`
696
- };
697
- const newMessages = [...messages, rejectionMessage];
698
- setMessages((previousMessages) => [...previousMessages, rejectionMessage]);
699
- await processStream(newMessages);
955
+ case REJECT:
956
+ setMessages((previousMessages) => [...previousMessages, {
957
+ role: ROLE.USER,
958
+ content: TURN_ABORTED_MESSAGE
959
+ }]);
960
+ setIsLoading(false);
961
+ setInterruptReason(INTERRUPT_REASON.REJECTED);
700
962
  break;
701
- }
702
963
  }
703
964
  }, [
704
965
  pendingToolCall,
@@ -706,6 +967,7 @@ function Chat({ model, onCommand, mode, onModeChange, sessionId }) {
706
967
  processStream
707
968
  ]);
708
969
  const handleSubmit = useCallback(async (value) => {
970
+ setInterruptReason(null);
709
971
  const userContent = value.trim();
710
972
  if (!userContent) return;
711
973
  if (userContent.startsWith("/")) {
@@ -744,8 +1006,16 @@ function Chat({ model, onCommand, mode, onModeChange, sessionId }) {
744
1006
  toolCall: pendingToolCall,
745
1007
  onDecision: handleToolApproval
746
1008
  }),
1009
+ interruptReason && !isLoading && /* @__PURE__ */ jsx(Box, {
1010
+ marginBottom: 1,
1011
+ children: /* @__PURE__ */ jsx(Text, {
1012
+ color: "red",
1013
+ children: interruptReason === INTERRUPT_REASON.REJECTED ? "❗ Tool call rejected." : "❗ Execution interrupted."
1014
+ })
1015
+ }),
747
1016
  !pendingPlan && !pendingToolCall && /* @__PURE__ */ jsx(Input, {
748
1017
  isDisabled: isLoading,
1018
+ onInterrupt: handleInterrupt,
749
1019
  onSubmit: handleSubmit
750
1020
  })
751
1021
  ]
@@ -949,7 +1219,6 @@ function renderApp() {
949
1219
  const tree = /* @__PURE__ */ jsx(App, {});
950
1220
  const app = render(tree, {
951
1221
  exitOnCtrlC: false,
952
- incrementalRendering: true,
953
1222
  maxFps: 60
954
1223
  });
955
1224
  setClearHandler(() => {
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import { exec } from "node:child_process";
8
8
  import { promisify } from "node:util";
9
9
  //#endregion
10
10
  //#region src/constants/package.ts
11
- var VERSION = "0.7.0";
11
+ var VERSION = "0.9.0";
12
12
  //#endregion
13
13
  //#region src/constants/prompt.ts
14
14
  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
@@ -134,22 +134,32 @@ function saveConfig(patch) {
134
134
  //#region src/utils/ollama.ts
135
135
  var { host, model: DEFAULT_MODEL } = loadConfig();
136
136
  var client = new Ollama({ host });
137
- async function* streamChat(messages, model = DEFAULT_MODEL, tools) {
137
+ async function* streamChat(messages, model = DEFAULT_MODEL, tools, signal) {
138
138
  const response = await client.chat({
139
139
  model,
140
140
  messages,
141
141
  stream: true,
142
- tools
142
+ tools,
143
+ // v8 ignore next
144
+ ...signal ? { signal } : {}
143
145
  });
144
- for await (const chunk of response) {
145
- if (chunk.message.content) yield {
146
- type: "content",
147
- content: chunk.message.content
148
- };
149
- if (chunk.message.tool_calls) yield {
150
- type: "tool_calls",
151
- tool_calls: chunk.message.tool_calls
152
- };
146
+ try {
147
+ for await (const chunk of response) {
148
+ // v8 ignore next 3
149
+ if (signal?.aborted) return;
150
+ if (chunk.message.content) yield {
151
+ type: "content",
152
+ content: chunk.message.content
153
+ };
154
+ if (chunk.message.tool_calls) yield {
155
+ type: "tool_calls",
156
+ tool_calls: chunk.message.tool_calls
157
+ };
158
+ }
159
+ } catch (error) {
160
+ // v8 ignore start
161
+ if (error instanceof Error && (error.name === "AbortError" || signal?.aborted)) return;
162
+ throw error;
153
163
  }
154
164
  }
155
165
  async function listModels() {
@@ -502,7 +512,7 @@ async function processRunStream(messages, model) {
502
512
  }
503
513
  async function main(args = process.argv.slice(2)) {
504
514
  if (!args.length) {
505
- const { renderApp } = await import("./assets/tui-CDaKDOEJ.js");
515
+ const { renderApp } = await import("./assets/tui-VKBxlYAz.js");
506
516
  process.stdout.write("\x1Bc");
507
517
  renderApp();
508
518
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-ollama",
3
- "version": "0.7.0",
3
+ "version": "0.9.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",
@@ -40,8 +40,11 @@
40
40
  ],
41
41
  "dependencies": {
42
42
  "@inkjs/ui": "2.0.0",
43
+ "@shikijs/cli": "4.0.2",
43
44
  "cac": "7.0.0",
44
45
  "ink": "7.0.2",
46
+ "marked": "15.0.12",
47
+ "marked-terminal": "7.3.0",
45
48
  "ollama": "0.6.3",
46
49
  "react": "19.2.6"
47
50
  },