@meowlynxsea/koi 0.1.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.
Files changed (109) hide show
  1. package/LICENSE +34 -0
  2. package/NOTICE +35 -0
  3. package/README.md +15 -0
  4. package/bin/koi +12 -0
  5. package/dist/highlights-eq9cgrbb.scm +604 -0
  6. package/dist/highlights-ghv9g403.scm +205 -0
  7. package/dist/highlights-hk7bwhj4.scm +284 -0
  8. package/dist/highlights-r812a2qc.scm +150 -0
  9. package/dist/highlights-x6tmsnaa.scm +115 -0
  10. package/dist/injections-73j83es3.scm +27 -0
  11. package/dist/main.js +489918 -0
  12. package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  13. package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
  14. package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  15. package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  16. package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
  17. package/package.json +51 -0
  18. package/src/agent/check-permissions.ts +239 -0
  19. package/src/agent/hooks/message-utils.ts +305 -0
  20. package/src/agent/hooks/types.ts +32 -0
  21. package/src/agent/hooks.ts +1560 -0
  22. package/src/agent/mode.ts +163 -0
  23. package/src/agent/monitor-registry.ts +308 -0
  24. package/src/agent/permission-ui.ts +71 -0
  25. package/src/agent/plan-ui.ts +74 -0
  26. package/src/agent/question-ui.ts +58 -0
  27. package/src/agent/session-fork.ts +299 -0
  28. package/src/agent/session-snapshots.ts +216 -0
  29. package/src/agent/session-store.ts +649 -0
  30. package/src/agent/session-tasks.ts +305 -0
  31. package/src/agent/session.ts +27 -0
  32. package/src/agent/subagent-registry.ts +176 -0
  33. package/src/agent/subagent.ts +194 -0
  34. package/src/agent/tool-orchestration.ts +55 -0
  35. package/src/agent/tools.ts +8 -0
  36. package/src/cli/args.ts +6 -0
  37. package/src/cli/commands.ts +5 -0
  38. package/src/commands/skills/index.ts +23 -0
  39. package/src/config/models.ts +6 -0
  40. package/src/config/settings.ts +392 -0
  41. package/src/main.tsx +64 -0
  42. package/src/services/mcp/client.ts +194 -0
  43. package/src/services/mcp/config.ts +232 -0
  44. package/src/services/mcp/connection-manager.ts +258 -0
  45. package/src/services/mcp/index.ts +80 -0
  46. package/src/services/mcp/mcp-commands.ts +114 -0
  47. package/src/services/mcp/stdio-transport.ts +246 -0
  48. package/src/services/mcp/types.ts +155 -0
  49. package/src/skills/SkillsMenu.tsx +370 -0
  50. package/src/skills/bundled/batch.ts +106 -0
  51. package/src/skills/bundled/debug.ts +86 -0
  52. package/src/skills/bundled/loremIpsum.ts +101 -0
  53. package/src/skills/bundled/remember.ts +97 -0
  54. package/src/skills/bundled/simplify.ts +100 -0
  55. package/src/skills/bundled/skillify.ts +123 -0
  56. package/src/skills/bundled/stuck.ts +101 -0
  57. package/src/skills/bundled/updateConfig.ts +228 -0
  58. package/src/skills/bundled.ts +46 -0
  59. package/src/skills/frontmatter.ts +179 -0
  60. package/src/skills/index.ts +87 -0
  61. package/src/skills/invoke.ts +231 -0
  62. package/src/skills/loader.ts +710 -0
  63. package/src/skills/substitution.ts +169 -0
  64. package/src/skills/types.ts +201 -0
  65. package/src/tools/agent.ts +143 -0
  66. package/src/tools/ask-user-question.ts +46 -0
  67. package/src/tools/bash.ts +148 -0
  68. package/src/tools/edit.ts +164 -0
  69. package/src/tools/glob.ts +102 -0
  70. package/src/tools/grep.ts +248 -0
  71. package/src/tools/index.ts +73 -0
  72. package/src/tools/list-mcp-resources.ts +74 -0
  73. package/src/tools/ls.ts +85 -0
  74. package/src/tools/mcp.ts +76 -0
  75. package/src/tools/monitor.ts +159 -0
  76. package/src/tools/plan-mode.ts +134 -0
  77. package/src/tools/read-mcp-resource.ts +79 -0
  78. package/src/tools/read.ts +137 -0
  79. package/src/tools/skill.ts +176 -0
  80. package/src/tools/task.ts +349 -0
  81. package/src/tools/types.ts +52 -0
  82. package/src/tools/webfetch-domains.ts +239 -0
  83. package/src/tools/webfetch.ts +533 -0
  84. package/src/tools/write.ts +101 -0
  85. package/src/tui/app.tsx +1178 -0
  86. package/src/tui/components/chat-panel.tsx +1071 -0
  87. package/src/tui/components/command-panel.tsx +261 -0
  88. package/src/tui/components/confirm-modal.tsx +135 -0
  89. package/src/tui/components/connect-modal.tsx +435 -0
  90. package/src/tui/components/connecting-modal.tsx +167 -0
  91. package/src/tui/components/edit-pending-modal.tsx +103 -0
  92. package/src/tui/components/exit-modal.tsx +131 -0
  93. package/src/tui/components/fork-modal.tsx +377 -0
  94. package/src/tui/components/image-preview-modal.tsx +141 -0
  95. package/src/tui/components/image-utils.ts +128 -0
  96. package/src/tui/components/info-bar.tsx +103 -0
  97. package/src/tui/components/input-box.tsx +352 -0
  98. package/src/tui/components/mcp/MCPSettings.tsx +386 -0
  99. package/src/tui/components/mcp/index.ts +7 -0
  100. package/src/tui/components/model-modal.tsx +310 -0
  101. package/src/tui/components/pending-area.tsx +88 -0
  102. package/src/tui/components/rename-modal.tsx +119 -0
  103. package/src/tui/components/session-modal.tsx +233 -0
  104. package/src/tui/components/side-bar.tsx +349 -0
  105. package/src/tui/components/tool-output.ts +6 -0
  106. package/src/tui/hooks/user-prompt-history.ts +114 -0
  107. package/src/tui/theme.ts +63 -0
  108. package/src/types/commands.ts +80 -0
  109. package/src/types/cross-spawn.d.ts +24 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Pending Area Component
3
+ *
4
+ * Displays queued (followUp) and sheer (steer) messages above the input box.
5
+ * Each message has its own cancel (×) button on the right side.
6
+ */
7
+
8
+ import stringWidth from "string-width";
9
+ import { isInternalNotification } from "../../agent/hooks.js";
10
+
11
+ interface PendingAreaProps {
12
+ steering: readonly string[];
13
+ followUp: readonly string[];
14
+ width?: number;
15
+ onRemove: (type: "sheer" | "queued", index: number) => void;
16
+ onEdit?: (type: "sheer" | "queued", index: number) => void;
17
+ }
18
+
19
+ function truncateText(text: string, maxWidth: number): string {
20
+ const w = stringWidth(text);
21
+ if (w <= maxWidth) return text;
22
+ let result = "";
23
+ let currentWidth = 0;
24
+ const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
25
+ for (const seg of segmenter.segment(text)) {
26
+ const g = seg.segment;
27
+ const gw = stringWidth(g);
28
+ if (currentWidth + gw + 3 > maxWidth) {
29
+ return result + "...";
30
+ }
31
+ result += g;
32
+ currentWidth += gw;
33
+ }
34
+ return result;
35
+ }
36
+
37
+ export function PendingArea({ steering, followUp, width = 80, onRemove, onEdit }: PendingAreaProps) {
38
+ const contentWidth = Math.max(1, width - 2);
39
+
40
+ // Filter out internal subagent task notifications from the steer list.
41
+ // When a background agent completes while the main agent is busy, it sends
42
+ // a <task-notification> via steer(). We don't want to show these in the UI.
43
+ const filteredSteering = steering.filter((t) => !isInternalNotification(t));
44
+
45
+ const all: { type: "sheer" | "queued"; text: string; index: number }[] = [
46
+ ...filteredSteering.map((t, i) => ({ type: "sheer" as const, text: t, index: i })),
47
+ ...followUp.map((t, i) => ({ type: "queued" as const, text: t, index: i })),
48
+ ];
49
+
50
+ if (all.length === 0) return null;
51
+
52
+ const editWidth = onEdit ? stringWidth(" ✎") : 0;
53
+ const lines = all.map((item) => {
54
+ const prefix = item.type === "sheer" ? "[Sheer] " : "[Queued] ";
55
+ const tagWidth = stringWidth(prefix) + stringWidth(" ×") + editWidth;
56
+ return {
57
+ ...item,
58
+ displayText: prefix + truncateText(item.text, Math.max(1, contentWidth - tagWidth)),
59
+ };
60
+ });
61
+
62
+ return (
63
+ <box width={width} flexDirection="column" paddingX={1}>
64
+ {lines.map((line) => (
65
+ <box key={`${line.type}-${line.index}`} flexDirection="row" width={contentWidth}>
66
+ <text fg={line.type === "sheer" ? "#ff79c6" : "#8be9fd"}>
67
+ {line.displayText}
68
+ </text>
69
+ <box flexGrow={1} />
70
+ {onEdit && (
71
+ <text
72
+ fg="#fbbf24"
73
+ onMouseUp={() => onEdit(line.type, line.index)}
74
+ >
75
+ {" ✎"}
76
+ </text>
77
+ )}
78
+ <text
79
+ fg="#ff5555"
80
+ onMouseUp={() => onRemove(line.type, line.index)}
81
+ >
82
+ {" ×"}
83
+ </text>
84
+ </box>
85
+ ))}
86
+ </box>
87
+ );
88
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Rename Session Modal
3
+ *
4
+ * Prompts the user for a new session title.
5
+ */
6
+
7
+ import { useEffect, useRef, useState } from "react";
8
+ import { useKeyboard } from "@opentui/react";
9
+ import { createTextAttributes } from "@opentui/core";
10
+ import type { TextareaRenderable } from "@opentui/core";
11
+
12
+ interface RenameModalProps {
13
+ isActive: boolean;
14
+ currentTitle: string;
15
+ onConfirm: (newTitle: string) => void;
16
+ onCancel: () => void;
17
+ }
18
+
19
+ export function RenameModal({ isActive, currentTitle, onConfirm, onCancel }: RenameModalProps) {
20
+ const inputRef = useRef<TextareaRenderable>(null);
21
+ const [value, setValue] = useState(currentTitle);
22
+
23
+ useEffect(() => {
24
+ if (isActive) {
25
+ setValue(currentTitle);
26
+ setTimeout(() => {
27
+ const ta = inputRef.current;
28
+ if (ta) {
29
+ ta.editBuffer.replaceText(currentTitle);
30
+ ta.focus();
31
+ }
32
+ }, 10);
33
+ }
34
+ }, [isActive, currentTitle]);
35
+
36
+ useKeyboard((key) => {
37
+ if (!isActive) return;
38
+ if (key.name === "escape") {
39
+ onCancel();
40
+ return;
41
+ }
42
+ if (key.name === "return") {
43
+ if (value.trim()) {
44
+ onConfirm(value.trim());
45
+ }
46
+ return;
47
+ }
48
+ });
49
+
50
+ const handleContentChange = () => {
51
+ const text = inputRef.current?.editBuffer.getText() ?? "";
52
+ setValue(text);
53
+ };
54
+
55
+ if (!isActive) return null;
56
+
57
+ return (
58
+ <box
59
+ position="absolute"
60
+ top={0}
61
+ left={0}
62
+ width="100%"
63
+ height="100%"
64
+ backgroundColor="#00000080"
65
+ alignItems="center"
66
+ justifyContent="center"
67
+ >
68
+ <box
69
+ width={50}
70
+ flexDirection="column"
71
+ borderStyle="rounded"
72
+ borderColor="#4a4a5a"
73
+ backgroundColor="#1a1a2e"
74
+ paddingX={2}
75
+ paddingY={1}
76
+ >
77
+ <text
78
+ attributes={createTextAttributes({ bold: true })}
79
+ fg="#ff79c6"
80
+ >
81
+ Rename Session
82
+ </text>
83
+ <box marginTop={1} height={1} backgroundColor="#16213e" paddingX={1}>
84
+ <textarea
85
+ ref={inputRef}
86
+ initialValue={currentTitle}
87
+ focused={isActive}
88
+ showCursor
89
+ height={1}
90
+ wrapMode="none"
91
+ textColor="#f8f8f2"
92
+ backgroundColor="#16213e"
93
+ onContentChange={handleContentChange}
94
+ />
95
+ </box>
96
+ <box marginTop={1} flexDirection="row" gap={2}>
97
+ <box
98
+ paddingX={2}
99
+ backgroundColor="#2dd4bf"
100
+ onMouseUp={() => value.trim() && onConfirm(value.trim())}
101
+ >
102
+ <text attributes={createTextAttributes({ bold: true })} fg="white">
103
+ Confirm
104
+ </text>
105
+ </box>
106
+ <box
107
+ paddingX={2}
108
+ backgroundColor="#f43f5e"
109
+ onMouseUp={onCancel}
110
+ >
111
+ <text attributes={createTextAttributes({ bold: true })} fg="white">
112
+ Cancel
113
+ </text>
114
+ </box>
115
+ </box>
116
+ </box>
117
+ </box>
118
+ );
119
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Session Selection Modal
3
+ *
4
+ * Secondary modal for browsing and switching between conversation sessions.
5
+ * Lists all persisted sessions with metadata.
6
+ */
7
+
8
+ import { useEffect, useMemo, useState } from "react";
9
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
10
+ import { createTextAttributes } from "@opentui/core";
11
+ import type { MouseEvent } from "@opentui/core";
12
+ import type { SessionMeta } from "../../agent/session-store.js";
13
+
14
+ interface SessionModalProps {
15
+ isActive: boolean;
16
+ onClose: () => void;
17
+ sessions: SessionMeta[];
18
+ currentSessionId: string | null;
19
+ onSelect: (sessionFile: string) => void;
20
+ onNewSession: () => void;
21
+ onDelete?: (sessionId: string) => void;
22
+ keyboardDisabled?: boolean;
23
+ }
24
+
25
+ function formatRelativeTime(date: Date): string {
26
+ // Handle invalid dates to prevent TextNodeRenderable errors
27
+ if (!(date instanceof Date) || isNaN(date.getTime())) {
28
+ return "unknown";
29
+ }
30
+
31
+ const now = new Date();
32
+ const diffMs = now.getTime() - date.getTime();
33
+ const diffMins = Math.floor(diffMs / 60000);
34
+ const diffHours = Math.floor(diffMins / 60);
35
+ const diffDays = Math.floor(diffHours / 24);
36
+
37
+ if (diffMins < 1) return "just now";
38
+ if (diffMins < 60) return `${diffMins}m ago`;
39
+ if (diffHours < 24) return `${diffHours}h ago`;
40
+ if (diffDays < 7) return `${diffDays}d ago`;
41
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
42
+ }
43
+
44
+ export function SessionModal({
45
+ isActive,
46
+ onClose,
47
+ sessions,
48
+ currentSessionId,
49
+ onSelect,
50
+ onNewSession,
51
+ onDelete,
52
+ keyboardDisabled,
53
+ }: SessionModalProps) {
54
+ const { width, height } = useTerminalDimensions();
55
+ const [selectedIndex, setSelectedIndex] = useState(0);
56
+ const [scrollOffset, setScrollOffset] = useState(0);
57
+
58
+ const panelWidth = Math.min(70, Math.max(50, Math.floor(width * 0.7)));
59
+ const listHeight = Math.min(14, Math.floor(height * 0.5));
60
+
61
+ // Reset when opened
62
+ useEffect(() => {
63
+ if (isActive) {
64
+ setSelectedIndex(0);
65
+ setScrollOffset(0);
66
+ }
67
+ }, [isActive]);
68
+
69
+ // Ensure selected index is valid
70
+ const safeIndex = useMemo(() => {
71
+ if (sessions.length === 0) return -1;
72
+ return Math.max(0, Math.min(selectedIndex, sessions.length - 1));
73
+ }, [selectedIndex, sessions.length]);
74
+
75
+ // Keep selected index valid when sessions change
76
+ useEffect(() => {
77
+ if (sessions.length === 0) {
78
+ setSelectedIndex(0);
79
+ } else {
80
+ setSelectedIndex((prev) => Math.min(prev, sessions.length - 1));
81
+ }
82
+ }, [sessions.length]);
83
+
84
+ // Auto-scroll selected into view
85
+ useEffect(() => {
86
+ if (safeIndex === -1) return;
87
+ if (safeIndex < scrollOffset) {
88
+ setScrollOffset(safeIndex);
89
+ } else if (safeIndex >= scrollOffset + listHeight) {
90
+ setScrollOffset(safeIndex - listHeight + 1);
91
+ }
92
+ }, [safeIndex, listHeight, scrollOffset]);
93
+
94
+ useKeyboard((key) => {
95
+ if (!isActive || keyboardDisabled) return;
96
+
97
+ if (key.name === "escape") {
98
+ onClose();
99
+ return;
100
+ }
101
+ if (key.name === "up") {
102
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
103
+ return;
104
+ }
105
+ if (key.name === "down") {
106
+ setSelectedIndex((prev) => Math.max(0, Math.min(sessions.length - 1, prev + 1)));
107
+ return;
108
+ }
109
+ if (key.name === "n") {
110
+ onNewSession();
111
+ return;
112
+ }
113
+ if (key.name === "return") {
114
+ const s = sessions[safeIndex];
115
+ if (s) {
116
+ onSelect(s.filePath);
117
+ }
118
+ return;
119
+ }
120
+ if (key.name === "d") {
121
+ const s = sessions[safeIndex];
122
+ if (s && onDelete) {
123
+ onDelete(s.id);
124
+ }
125
+ return;
126
+ }
127
+ });
128
+
129
+ if (!isActive) return null;
130
+
131
+ const visibleSessions = sessions.slice(scrollOffset, scrollOffset + listHeight);
132
+
133
+ return (
134
+ <box
135
+ position="absolute"
136
+ top={0}
137
+ left={0}
138
+ width="100%"
139
+ height="100%"
140
+ backgroundColor="#00000080"
141
+ alignItems="center"
142
+ justifyContent="center"
143
+ >
144
+ <box
145
+ width={panelWidth}
146
+ flexDirection="column"
147
+ borderStyle="rounded"
148
+ borderColor="#4a4a5a"
149
+ backgroundColor="#1a1a2e"
150
+ paddingX={2}
151
+ paddingY={1}
152
+ >
153
+ {/* Header */}
154
+ <box flexDirection="row" justifyContent="space-between">
155
+ <text attributes={createTextAttributes({ bold: true })} fg="#ff79c6">
156
+ Sessions
157
+ </text>
158
+ <text fg="#6c6c7c" attributes={createTextAttributes({ dim: true })}>
159
+ {`${sessions.length} total`}
160
+ </text>
161
+ </box>
162
+
163
+ {/* Session list */}
164
+ <box
165
+ height={listHeight}
166
+ flexDirection="column"
167
+ overflow="hidden"
168
+ marginTop={1}
169
+ >
170
+ {sessions.length === 0 && (
171
+ <box height={1}>
172
+ <text fg="#6c6c7c">No sessions found.</text>
173
+ </box>
174
+ )}
175
+ {visibleSessions.map((s, idx) => {
176
+ const flatIndex = scrollOffset + idx;
177
+ const isSelected = flatIndex === safeIndex;
178
+ const isCurrent = s.id === currentSessionId;
179
+
180
+ // Ensure safe string values for rendering
181
+ const safeTitle = String(s.title ?? "Untitled Session");
182
+ const safeMessageCount = typeof s.messageCount === "number" ? s.messageCount : 0;
183
+ const safeUpdatedAt =
184
+ s.updatedAt instanceof Date && !isNaN(s.updatedAt.getTime())
185
+ ? s.updatedAt
186
+ : new Date();
187
+
188
+ return (
189
+ <box
190
+ key={`s-${s.id}-${flatIndex}`}
191
+ height={1}
192
+ backgroundColor={isSelected ? "#44475a" : undefined}
193
+ flexDirection="row"
194
+ onMouseUp={(e: MouseEvent) => {
195
+ e.stopPropagation();
196
+ onSelect(s.filePath);
197
+ }}
198
+ >
199
+ <text
200
+ fg={isSelected ? "#ff79c6" : isCurrent ? "#00f5ff" : "#f8f8f2"}
201
+ attributes={createTextAttributes({ bold: isCurrent })}
202
+ width={Math.max(1, panelWidth - 24)}
203
+ truncate={true}
204
+ >
205
+ {isCurrent ? "● " : " "}
206
+ {safeTitle}
207
+ </text>
208
+ <box flexDirection="row" gap={1}>
209
+ <text fg="#6c6c7c" attributes={createTextAttributes({ dim: true })}>
210
+ {String(safeMessageCount)}msg
211
+ </text>
212
+ <text fg="#6c6c7c" attributes={createTextAttributes({ dim: true })} width={8}>
213
+ {formatRelativeTime(safeUpdatedAt)}
214
+ </text>
215
+ </box>
216
+ </box>
217
+ );
218
+ })}
219
+ </box>
220
+
221
+ {/* Footer hints */}
222
+ <box marginTop={1} flexDirection="row" justifyContent="space-between">
223
+ <text fg="#6c6c7c" attributes={createTextAttributes({ dim: true })}>
224
+ ↑↓ Navigate Enter Switch n New d Delete
225
+ </text>
226
+ <text fg="#6c6c7c" attributes={createTextAttributes({ dim: true })}>
227
+ Esc Close
228
+ </text>
229
+ </box>
230
+ </box>
231
+ </box>
232
+ );
233
+ }