@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,131 @@
1
+ /**
2
+ * Exit Confirmation Modal
3
+ *
4
+ * Displays a centered dialog asking the user to confirm application exit.
5
+ * Supports keyboard (Y/Enter/N/Esc) and native mouse click.
6
+ */
7
+
8
+ import { useState } from "react";
9
+ import { useKeyboard } from "@opentui/react";
10
+ import { createTextAttributes } from "@opentui/core";
11
+ import type { MouseEvent } from "@opentui/core";
12
+
13
+ interface ExitModalProps {
14
+ isActive: boolean;
15
+ onConfirm: () => void;
16
+ onCancel: () => void;
17
+ }
18
+
19
+ function Button({
20
+ label,
21
+ fgColor,
22
+ bgColor,
23
+ hoverBgColor,
24
+ isActive,
25
+ onClick,
26
+ }: {
27
+ label: string;
28
+ fgColor: string;
29
+ bgColor: string;
30
+ hoverBgColor: string;
31
+ isActive: boolean;
32
+ onClick: () => void;
33
+ }) {
34
+ const [hover, setHover] = useState(false);
35
+ const currentBg = hover ? hoverBgColor : bgColor;
36
+
37
+ const handleMouseUp = (e: MouseEvent) => {
38
+ if (isActive) {
39
+ e.stopPropagation();
40
+ onClick();
41
+ }
42
+ };
43
+
44
+ const handleMouseOver = () => {
45
+ if (isActive) setHover(true);
46
+ };
47
+
48
+ const handleMouseOut = () => {
49
+ if (isActive) setHover(false);
50
+ };
51
+
52
+ return (
53
+ <box
54
+ paddingX={2}
55
+ backgroundColor={currentBg}
56
+ onMouseUp={handleMouseUp}
57
+ onMouseOver={handleMouseOver}
58
+ onMouseOut={handleMouseOut}
59
+ >
60
+ <text fg={fgColor} attributes={createTextAttributes({ bold: true })}>
61
+ {label}
62
+ </text>
63
+ </box>
64
+ );
65
+ }
66
+
67
+ export function ExitModal({ isActive, onConfirm, onCancel }: ExitModalProps) {
68
+ useKeyboard((key) => {
69
+ if (!isActive) return;
70
+ if (key.name === "y" || key.name === "return") {
71
+ onConfirm();
72
+ } else if (key.name === "n" || key.name === "escape") {
73
+ onCancel();
74
+ }
75
+ });
76
+
77
+ if (!isActive) return null;
78
+
79
+ return (
80
+ <box
81
+ position="absolute"
82
+ top={0}
83
+ left={0}
84
+ width="100%"
85
+ height="100%"
86
+ backgroundColor="#00000080"
87
+ alignItems="center"
88
+ justifyContent="center"
89
+ >
90
+ <box
91
+ borderStyle="rounded"
92
+ borderColor="#4a4a5a"
93
+ backgroundColor="#1a1a2e"
94
+ paddingX={3}
95
+ paddingY={1}
96
+ flexDirection="column"
97
+ alignItems="center"
98
+ >
99
+ <text attributes={createTextAttributes({ bold: true })} fg="#ff79c6">
100
+ Exit Koi?
101
+ </text>
102
+ <text fg="#6c6c7c" attributes={createTextAttributes({ dim: true })}>
103
+ Are you sure you want to exit?
104
+ </text>
105
+ <box marginTop={1} flexDirection="row" gap={2}>
106
+ <Button
107
+ label="Exit"
108
+ fgColor="white"
109
+ bgColor="#f43f5e"
110
+ hoverBgColor="#fb7185"
111
+ isActive={isActive}
112
+ onClick={onConfirm}
113
+ />
114
+ <Button
115
+ label="Stay"
116
+ fgColor="white"
117
+ bgColor="#2dd4bf"
118
+ hoverBgColor="#5eead4"
119
+ isActive={isActive}
120
+ onClick={onCancel}
121
+ />
122
+ </box>
123
+ <box marginTop={1}>
124
+ <text fg="#6c6c7c" attributes={createTextAttributes({ dim: true })}>
125
+ Y/Enter Confirm N/Esc Cancel
126
+ </text>
127
+ </box>
128
+ </box>
129
+ </box>
130
+ );
131
+ }
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Fork Modal — Conversation Branch View
3
+ *
4
+ * Displays only user and assistant messages from the session tree
5
+ * as an indented, navigable list. Users can select a user message
6
+ * to fork from that point in the conversation history.
7
+ */
8
+
9
+ import { useEffect, useMemo, useState } from "react";
10
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
11
+ import { createTextAttributes } from "@opentui/core";
12
+ import type { MouseEvent } from "@opentui/core";
13
+ import type { AgentSession } from "@mariozechner/pi-coding-agent";
14
+ import { isInternalNotification } from "../../agent/hooks.js";
15
+
16
+ type SessionManagerType = AgentSession["sessionManager"];
17
+ type SessionTreeNode = ReturnType<SessionManagerType["getTree"]>[number];
18
+
19
+ interface ForkModalProps {
20
+ isActive: boolean;
21
+ onClose: () => void;
22
+ session: AgentSession | null;
23
+ onFork: (entryId: string) => void;
24
+ }
25
+
26
+ interface TreeRow {
27
+ node: SessionTreeNode;
28
+ depth: number;
29
+ index: number;
30
+ isUserMessage: boolean;
31
+ displayText: string;
32
+ isLast: boolean;
33
+ parentIsLast: boolean[];
34
+ }
35
+
36
+ interface MessageEntry {
37
+ type: "message";
38
+ message: {
39
+ role: string;
40
+ content: unknown;
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Type Guards
46
+ *
47
+ * isMessageEntry narrows the generic SessionTreeNode entry to a message-shaped object.
48
+ * isVisibleNode filters the tree to only user/assistant messages (hides tool_results, compaction nodes, etc.).
49
+ */
50
+
51
+ function isMessageEntry(entry: unknown): entry is MessageEntry {
52
+ return (
53
+ typeof entry === "object" &&
54
+ entry !== null &&
55
+ "type" in entry &&
56
+ (entry as Record<string, unknown>)["type"] === "message" &&
57
+ "message" in entry &&
58
+ typeof (entry as Record<string, unknown>)["message"] === "object" &&
59
+ (entry as Record<string, unknown>)["message"] !== null
60
+ );
61
+ }
62
+
63
+ function isVisibleNode(node: SessionTreeNode): boolean {
64
+ const entry = node.entry;
65
+ if (!isMessageEntry(entry)) return false;
66
+ if (entry.message.role !== "user") return false;
67
+ // Hide internal subagent notifications — they are not real user messages.
68
+ const text = extractUserText(entry.message.content);
69
+ return !isInternalNotification(text);
70
+ }
71
+
72
+ function isUserMessageEntry(entry: unknown): boolean {
73
+ return isMessageEntry(entry) && entry.message.role === "user";
74
+ }
75
+
76
+ /**
77
+ * Text Extraction
78
+ *
79
+ * Pi messages may be plain strings or arrays of {type, text} blocks.
80
+ * These helpers normalize both shapes into a single string for the tree view.
81
+ */
82
+
83
+ function extractTextFromBlocks(content: unknown): string {
84
+ if (typeof content === "string") return content;
85
+ if (!Array.isArray(content)) return "";
86
+
87
+ return content
88
+ .filter((block): block is { type: string; text?: string } =>
89
+ typeof block === "object" && block !== null && "type" in block
90
+ )
91
+ .filter((block) => block.type === "text")
92
+ .map((block) => block.text ?? "")
93
+ .join("");
94
+ }
95
+
96
+ function extractUserText(content: unknown): string {
97
+ return extractTextFromBlocks(content) || "(empty)";
98
+ }
99
+
100
+ function extractAssistantText(msg: { content: unknown }): string {
101
+ const text = extractTextFromBlocks(msg.content);
102
+ return text.slice(0, 50) || "(assistant)";
103
+ }
104
+
105
+ function formatEntry(node: SessionTreeNode): string {
106
+ const entry = node.entry;
107
+ if (!isMessageEntry(entry)) return "";
108
+
109
+ const msg = entry.message;
110
+ if (msg.role === "user") return extractUserText(msg.content);
111
+ if (msg.role === "assistant") return extractAssistantText(msg as { content: unknown });
112
+ return "";
113
+ }
114
+
115
+ /**
116
+ * Tree Flattening
117
+ *
118
+ * Converts the recursive SessionTreeNode structure into a flat list of TreeRows.
119
+ * Hidden nodes (tool results, compaction markers) are skipped but their children are promoted
120
+ * to the same depth so the conversation flow stays visually continuous.
121
+ */
122
+
123
+ function flattenTree(
124
+ nodes: SessionTreeNode[],
125
+ depth = 0,
126
+ parentIsLast: boolean[] = [],
127
+ result: TreeRow[] = []
128
+ ): TreeRow[] {
129
+ const visibleNodes = nodes.filter(isVisibleNode);
130
+ let visibleIndex = 0;
131
+
132
+ for (const node of nodes) {
133
+ const isVisible = isVisibleNode(node);
134
+
135
+ if (isVisible) {
136
+ const isLast = visibleIndex === visibleNodes.length - 1;
137
+ result.push({
138
+ node,
139
+ depth,
140
+ index: result.length,
141
+ isUserMessage: isUserMessageEntry(node.entry),
142
+ displayText: formatEntry(node),
143
+ isLast,
144
+ parentIsLast: [...parentIsLast],
145
+ });
146
+
147
+ if (node.children.length > 0) {
148
+ flattenTree(node.children, depth + 1, [...parentIsLast, isLast], result);
149
+ }
150
+ visibleIndex++;
151
+ } else if (node.children.length > 0) {
152
+ // Hidden nodes pass children through at same depth
153
+ flattenTree(node.children, depth, [...parentIsLast], result);
154
+ }
155
+ }
156
+
157
+ return result;
158
+ }
159
+
160
+ /**
161
+ * Tree Prefix
162
+ *
163
+ * Builds the "│ └ " ASCII art prefix for each tree row based on depth and sibling position.
164
+ * Deep trees are truncated with "…" so the rightmost branch structure remains visible.
165
+ */
166
+
167
+ function treePrefix(depth: number, parentIsLast: boolean[], isLast: boolean): string {
168
+ let prefix = "";
169
+ for (let i = 0; i < depth; i++) {
170
+ prefix += parentIsLast[i] ? " " : "│";
171
+ }
172
+ prefix += isLast ? "└ " : "├ ";
173
+ return prefix;
174
+ }
175
+
176
+ function getVisiblePrefix(row: TreeRow, contentWidth: number): string {
177
+ const prefix = treePrefix(row.depth, row.parentIsLast, row.isLast);
178
+ if (prefix.length <= contentWidth) return prefix;
179
+ return "…" + prefix.slice(-(contentWidth - 2));
180
+ }
181
+
182
+ /**
183
+ * Default Selection
184
+ *
185
+ * When the modal opens, auto-selects the last user message on the current active branch
186
+ * so the user can press Enter immediately without manual navigation.
187
+ */
188
+
189
+ function findDefaultIndex(rows: TreeRow[], session: AgentSession): number {
190
+ const selectable = rows.filter((r) => r.isUserMessage);
191
+ if (selectable.length === 0) return 0;
192
+
193
+ try {
194
+ const branch = session.sessionManager.getBranch();
195
+ const lastUserEntry = [...branch]
196
+ .reverse()
197
+ .find((e) => isUserMessageEntry(e));
198
+
199
+ if (lastUserEntry) {
200
+ const idx = selectable.findIndex((r) => r.node.entry.id === lastUserEntry.id);
201
+ if (idx >= 0) return idx;
202
+ }
203
+ } catch {
204
+ // ignore
205
+ }
206
+
207
+ return selectable.length - 1;
208
+ }
209
+
210
+ /**
211
+ * ForkModal
212
+ *
213
+ * Interactive tree view of the conversation history.
214
+ * Only user messages are selectable (Enter / mouse click) because forking
215
+ * from an assistant node would cut off the user's original question.
216
+ */
217
+
218
+ export function ForkModal({
219
+ isActive,
220
+ onClose,
221
+ session,
222
+ onFork,
223
+ }: ForkModalProps) {
224
+ const { width, height } = useTerminalDimensions();
225
+ const [selectedIndex, setSelectedIndex] = useState(0);
226
+ const [scrollOffset, setScrollOffset] = useState(0);
227
+ const [rows, setRows] = useState<TreeRow[]>([]);
228
+
229
+ // Calculate panel dimensions
230
+ const panelWidth = Math.min(80, Math.max(52, Math.floor(width * 0.8)));
231
+ const listHeight = Math.min(16, Math.floor(height * 0.5));
232
+ const contentWidth = Math.max(1, panelWidth - 4);
233
+
234
+ // Recompute tree rows when modal opens
235
+ useEffect(() => {
236
+ if (!isActive || !session) {
237
+ setRows([]);
238
+ return;
239
+ }
240
+ try {
241
+ const tree = session.sessionManager.getTree();
242
+ const newRows = flattenTree(tree);
243
+ setRows(newRows);
244
+ setSelectedIndex(findDefaultIndex(newRows, session));
245
+ } catch {
246
+ setRows([]);
247
+ setSelectedIndex(0);
248
+ }
249
+ }, [isActive, session]);
250
+
251
+ const selectableRows = useMemo(() => rows.filter((r) => r.isUserMessage), [rows]);
252
+
253
+ const safeIndex = useMemo(() => {
254
+ if (selectableRows.length === 0) return -1;
255
+ return Math.max(0, Math.min(selectedIndex, selectableRows.length - 1));
256
+ }, [selectedIndex, selectableRows.length]);
257
+
258
+ const selectedRow = safeIndex >= 0 ? selectableRows[safeIndex] : null;
259
+
260
+ // Keep selected row near the 5th visible line
261
+ useEffect(() => {
262
+ if (!selectedRow) {
263
+ setScrollOffset(0);
264
+ return;
265
+ }
266
+ const flatIndex = selectedRow.index;
267
+ const maxOffset = Math.max(0, rows.length - listHeight);
268
+ setScrollOffset(Math.max(0, Math.min(flatIndex - 4, maxOffset)));
269
+ }, [selectedRow, listHeight, rows.length]);
270
+
271
+ useKeyboard((key) => {
272
+ if (!isActive) return;
273
+
274
+ if (key.name === "escape") {
275
+ onClose();
276
+ return;
277
+ }
278
+ if (key.name === "up") {
279
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
280
+ return;
281
+ }
282
+ if (key.name === "down") {
283
+ setSelectedIndex((prev) => Math.max(0, Math.min(selectableRows.length - 1, prev + 1)));
284
+ return;
285
+ }
286
+ if (key.name === "return") {
287
+ const row = selectableRows[safeIndex];
288
+ if (row) onFork(row.node.entry.id);
289
+ return;
290
+ }
291
+ });
292
+
293
+ if (!isActive) return null;
294
+
295
+ const visibleRows = rows.slice(scrollOffset, scrollOffset + listHeight);
296
+
297
+ return (
298
+ <box
299
+ position="absolute"
300
+ top={0}
301
+ left={0}
302
+ width="100%"
303
+ height="100%"
304
+ backgroundColor="#00000080"
305
+ alignItems="center"
306
+ justifyContent="center"
307
+ >
308
+ <box
309
+ alignSelf="center"
310
+ width={panelWidth}
311
+ flexDirection="column"
312
+ borderStyle="rounded"
313
+ borderColor="#4a4a5a"
314
+ backgroundColor="#1a1a2e"
315
+ paddingX={2}
316
+ paddingY={1}
317
+ >
318
+ {/* Header */}
319
+ <text attributes={createTextAttributes({ bold: true })} fg="#ff79c6">
320
+ Fork Session
321
+ </text>
322
+ <text fg="#6c6c7c" attributes={createTextAttributes({ dim: true })} marginTop={1}>
323
+ Select a user message to branch from:
324
+ </text>
325
+
326
+ {/* Tree list */}
327
+ <box height={listHeight} flexDirection="column" overflow="hidden" marginTop={1}>
328
+ {rows.length === 0 && (
329
+ <box height={1}>
330
+ <text fg="#6c6c7c">No messages available.</text>
331
+ </box>
332
+ )}
333
+ {visibleRows.map((row) => {
334
+ const isSelected = selectedRow?.index === row.index;
335
+ const prefix = getVisiblePrefix(row, contentWidth);
336
+ const availableWidth = Math.max(1, contentWidth - prefix.length);
337
+ const displayText =
338
+ row.displayText.length > availableWidth
339
+ ? row.displayText.slice(0, availableWidth - 1) + "…"
340
+ : row.displayText;
341
+
342
+ const fgColor = row.isUserMessage
343
+ ? isSelected ? "#ff79c6" : "#f8f8f2"
344
+ : "#6c6c7c";
345
+
346
+ return (
347
+ <box
348
+ key={`t-${row.node.entry.id}-${row.index}`}
349
+ height={1}
350
+ backgroundColor={isSelected ? "#44475a" : undefined}
351
+ flexDirection="row"
352
+ onMouseUp={(e: MouseEvent) => {
353
+ e.stopPropagation();
354
+ if (row.isUserMessage) onFork(row.node.entry.id);
355
+ }}
356
+ >
357
+ <text fg={fgColor} attributes={createTextAttributes({ dim: !row.isUserMessage })}>
358
+ {prefix}{displayText}
359
+ </text>
360
+ </box>
361
+ );
362
+ })}
363
+ </box>
364
+
365
+ {/* Footer hints */}
366
+ <box marginTop={1} flexDirection="row" justifyContent="space-between">
367
+ <text fg="#6c6c7c" attributes={createTextAttributes({ dim: true })}>
368
+ ↑↓ Navigate Enter Fork
369
+ </text>
370
+ <text fg="#6c6c7c" attributes={createTextAttributes({ dim: true })}>
371
+ Esc Close
372
+ </text>
373
+ </box>
374
+ </box>
375
+ </box>
376
+ );
377
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Image Preview Modal
3
+ *
4
+ * Shows an image URL/path in a modal overlay with a coloured half-block
5
+ * preview generated via jimp (pure JS, no external binaries).
6
+ */
7
+
8
+ import { useState, useEffect } from "react";
9
+ import { useKeyboard } from "@opentui/react";
10
+ import { createTextAttributes } from "@opentui/core";
11
+ import { imageToHalfBlocks, type ImageRow } from "./image-utils.js";
12
+
13
+ interface ImagePreviewModalProps {
14
+ isActive: boolean;
15
+ url: string;
16
+ onClose: () => void;
17
+ terminalWidth: number;
18
+ terminalHeight: number;
19
+ }
20
+
21
+ function ImagePreviewContent({ rows }: { rows: ImageRow[] }) {
22
+ return (
23
+ <box flexDirection="column">
24
+ {rows.map((row, y) => (
25
+ <text key={y}>
26
+ {row.map((cell, x) => (
27
+ <span key={x} fg={cell.fg} bg={cell.bg}>
28
+ {"▄"}
29
+ </span>
30
+ ))}
31
+ </text>
32
+ ))}
33
+ </box>
34
+ );
35
+ }
36
+
37
+ export function ImagePreviewModal({
38
+ isActive,
39
+ url,
40
+ onClose,
41
+ terminalWidth,
42
+ terminalHeight,
43
+ }: ImagePreviewModalProps) {
44
+ const [rows, setRows] = useState<ImageRow[] | null>(null);
45
+ const [loading, setLoading] = useState(false);
46
+
47
+ useKeyboard((key) => {
48
+ if (!isActive) return;
49
+ if (key.name === "escape" || key.name === "q") {
50
+ onClose();
51
+ }
52
+ });
53
+
54
+ const modalW = Math.max(20, Math.floor(terminalWidth * 0.8));
55
+ const modalH = Math.max(10, Math.floor(terminalHeight * 0.8));
56
+ const imgMaxW = Math.max(10, modalW - 4);
57
+ const imgMaxH = Math.max(4, modalH - 4);
58
+
59
+ useEffect(() => {
60
+ if (!isActive) {
61
+ setRows(null);
62
+ setLoading(false);
63
+ return;
64
+ }
65
+
66
+ let cancelled = false;
67
+
68
+ async function loadPreview() {
69
+ setLoading(true);
70
+ try {
71
+ const data = await imageToHalfBlocks(url, imgMaxW, imgMaxH);
72
+ if (!cancelled) setRows(data);
73
+ } catch {
74
+ if (!cancelled) setRows(null);
75
+ } finally {
76
+ if (!cancelled) setLoading(false);
77
+ }
78
+ }
79
+
80
+ void loadPreview();
81
+ return () => {
82
+ cancelled = true;
83
+ };
84
+ }, [isActive, url, imgMaxW, imgMaxH]);
85
+
86
+ if (!isActive) return null;
87
+
88
+ return (
89
+ <box
90
+ position="absolute"
91
+ top={0}
92
+ left={0}
93
+ width="100%"
94
+ height="100%"
95
+ backgroundColor="#00000080"
96
+ alignItems="center"
97
+ justifyContent="center"
98
+ >
99
+ <box
100
+ borderStyle="rounded"
101
+ borderColor="#4a4a5a"
102
+ backgroundColor="#1a1a2e"
103
+ paddingX={2}
104
+ paddingY={1}
105
+ flexDirection="column"
106
+ width={modalW}
107
+ height={modalH}
108
+ >
109
+ {/* Header row */}
110
+ <box flexDirection="row" justifyContent="space-between" alignItems="center">
111
+ <text fg="#8be9fd" attributes={createTextAttributes({ bold: true })}>
112
+ Image Preview
113
+ </text>
114
+ <text fg="#6c6c7c" attributes={createTextAttributes({ dim: true })}>
115
+ Esc/Q
116
+ </text>
117
+ </box>
118
+
119
+ {/* URL — single line, truncated if too long */}
120
+ <text fg="#6c6c7c" wrapMode="none" truncate={true}>
121
+ {url}
122
+ </text>
123
+
124
+ {/* Image area — centered both horizontally and vertically */}
125
+ <box flexDirection="column" flexGrow={1} alignItems="center" justifyContent="center">
126
+ {rows && <ImagePreviewContent rows={rows} />}
127
+ {!rows && !loading && (
128
+ <text fg="#6c6c7c" attributes={createTextAttributes({ dim: true })}>
129
+ Could not render image
130
+ </text>
131
+ )}
132
+ {loading && (
133
+ <text fg="#6c6c7c" attributes={createTextAttributes({ dim: true })}>
134
+ Loading...
135
+ </text>
136
+ )}
137
+ </box>
138
+ </box>
139
+ </box>
140
+ );
141
+ }