@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,128 @@
1
+ /**
2
+ * Image Utilities
3
+ *
4
+ * Converts images to terminal-renderable half-block art using jimp.
5
+ * Each terminal cell displays 2 vertical pixels as a single coloured
6
+ * half-block character (▄), with bg = top pixel and fg = bottom pixel.
7
+ */
8
+
9
+ import { Jimp } from "jimp";
10
+ import { existsSync } from "fs";
11
+ import { resolve } from "path";
12
+
13
+ export type ImageCell = { fg: string; bg: string };
14
+ export type ImageRow = ImageCell[];
15
+
16
+ function cacheKey(url: string, w: number, h: number): string {
17
+ return `${url}::${w}x${h}`;
18
+ }
19
+
20
+ const imageCache = new Map<string, ImageRow[] | null>();
21
+
22
+ function rgbaToHex(r: number, g: number, b: number): string {
23
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
24
+ }
25
+
26
+ async function fetchImageBuffer(url: string): Promise<Buffer> {
27
+ const controller = new AbortController();
28
+ const timeout = setTimeout(() => controller.abort(), 8000);
29
+
30
+ try {
31
+ const response = await fetch(url, { signal: controller.signal });
32
+ clearTimeout(timeout);
33
+
34
+ if (!response.ok) {
35
+ throw new Error(`HTTP ${response.status}`);
36
+ }
37
+
38
+ const contentLength = response.headers.get("content-length");
39
+ if (contentLength && parseInt(contentLength, 10) > 10 * 1024 * 1024) {
40
+ throw new Error("Image too large");
41
+ }
42
+
43
+ return Buffer.from(await response.arrayBuffer());
44
+ } catch (err) {
45
+ clearTimeout(timeout);
46
+ throw err;
47
+ }
48
+ }
49
+
50
+ async function readImage(url: string) {
51
+ if (url.startsWith("http://") || url.startsWith("https://")) {
52
+ const buffer = await fetchImageBuffer(url);
53
+ return Jimp.fromBuffer(buffer);
54
+ }
55
+
56
+ const path = resolve(url);
57
+ if (!existsSync(path)) {
58
+ throw new Error(`File not found: ${path}`);
59
+ }
60
+ return Jimp.read(path);
61
+ }
62
+
63
+ export async function imageToHalfBlocks(
64
+ url: string,
65
+ maxWidth: number,
66
+ maxHeight: number
67
+ ): Promise<ImageRow[] | null> {
68
+ const key = cacheKey(url, maxWidth, maxHeight);
69
+ if (imageCache.has(key)) {
70
+ return imageCache.get(key)!;
71
+ }
72
+
73
+ try {
74
+ const image = await readImage(url);
75
+
76
+ // Scale to fit maxWidth x maxHeight terminal cells while preserving
77
+ // the original aspect ratio. Each terminal row displays 2 image rows.
78
+ const originalW = image.bitmap.width;
79
+ const originalH = image.bitmap.height;
80
+
81
+ const scale = Math.min(
82
+ maxWidth / originalW,
83
+ (maxHeight * 2) / originalH
84
+ );
85
+
86
+ const pixelW = Math.max(1, Math.round(originalW * scale));
87
+ const pixelH = Math.max(1, Math.round(originalH * scale));
88
+
89
+ image.resize({ w: pixelW, h: pixelH });
90
+
91
+ const finalW = image.bitmap.width;
92
+ const finalH = image.bitmap.height;
93
+ const rows: ImageRow[] = [];
94
+
95
+ for (let y = 0; y < finalH; y += 2) {
96
+ const row: ImageCell[] = [];
97
+ for (let x = 0; x < finalW; x++) {
98
+ const topColor = image.getPixelColor(x, y);
99
+ const topR = (topColor >>> 24) & 0xff;
100
+ const topG = (topColor >>> 16) & 0xff;
101
+ const topB = (topColor >>> 8) & 0xff;
102
+
103
+ let bottomR = topR;
104
+ let bottomG = topG;
105
+ let bottomB = topB;
106
+
107
+ if (y + 1 < finalH) {
108
+ const bottomColor = image.getPixelColor(x, y + 1);
109
+ bottomR = (bottomColor >>> 24) & 0xff;
110
+ bottomG = (bottomColor >>> 16) & 0xff;
111
+ bottomB = (bottomColor >>> 8) & 0xff;
112
+ }
113
+
114
+ row.push({
115
+ bg: rgbaToHex(topR, topG, topB),
116
+ fg: rgbaToHex(bottomR, bottomG, bottomB),
117
+ });
118
+ }
119
+ rows.push(row);
120
+ }
121
+
122
+ imageCache.set(key, rows);
123
+ return rows;
124
+ } catch {
125
+ imageCache.set(key, null);
126
+ return null;
127
+ }
128
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Info Bar Component
3
+ *
4
+ * Persistent footer line: scrolling keybinding hints on the left,
5
+ * empty space on the right reserved for the koi pet.
6
+ */
7
+
8
+ import { useState, useEffect, useRef } from "react";
9
+ import { createTextAttributes } from "@opentui/core";
10
+ import type { MouseEvent } from "@opentui/core";
11
+
12
+ const HINT_TEXT =
13
+ "Enter Send/Steer Ctrl+Enter Queue Shift+Enter Newline Ctrl+P Command Ctrl+O Expand/Collapse Ctrl+C Clear/Abort/Exit";
14
+ const EXIT_TEXT = "Confirm exit in dialog";
15
+ const SCROLL_INTERVAL_MS = 300;
16
+ const MAX_HINT_WIDTH_RATIO = 0.6; // max 60% of width for hints
17
+ const YOLO_BUTTON_WIDTH = 6; // " YOLO " = 6 chars with padding
18
+
19
+ // Gray for disabled, rose red for enabled
20
+ const DISABLED_COLOR = "#4a4a5a";
21
+ const ENABLED_COLOR = "#ff6b9d";
22
+
23
+ interface InfoBarProps {
24
+ width?: number;
25
+ exitMode?: boolean;
26
+ yoloMode?: boolean;
27
+ onToggleYolo?: () => void;
28
+ }
29
+
30
+ export function InfoBar({ width = 80, exitMode = false, yoloMode = false, onToggleYolo }: InfoBarProps) {
31
+ const [scrollOffset, setScrollOffset] = useState(0);
32
+ const [scrollDirection, setScrollDirection] = useState(1);
33
+ const lastWidthRef = useRef(width);
34
+
35
+ useEffect(() => {
36
+ lastWidthRef.current = width;
37
+ }, [width]);
38
+
39
+ useEffect(() => {
40
+ if (exitMode) return;
41
+ const maxWidth = Math.floor(lastWidthRef.current * MAX_HINT_WIDTH_RATIO);
42
+ const textWidth = HINT_TEXT.length;
43
+ if (textWidth <= maxWidth) return;
44
+
45
+ const timer = setInterval(() => {
46
+ const maxOffset = textWidth - maxWidth;
47
+ setScrollOffset((prev) => {
48
+ const next = prev + scrollDirection;
49
+ if (next >= maxOffset) {
50
+ setScrollDirection(-1);
51
+ return maxOffset;
52
+ }
53
+ if (next <= 0) {
54
+ setScrollDirection(1);
55
+ return 0;
56
+ }
57
+ return next;
58
+ });
59
+ }, SCROLL_INTERVAL_MS);
60
+
61
+ return () => clearInterval(timer);
62
+ }, [exitMode, scrollDirection]);
63
+
64
+ let displayText: string;
65
+ if (exitMode) {
66
+ displayText = EXIT_TEXT;
67
+ } else {
68
+ const maxHintWidth = Math.floor(width * MAX_HINT_WIDTH_RATIO);
69
+ const textWidth = HINT_TEXT.length;
70
+ if (textWidth <= maxHintWidth) {
71
+ displayText = HINT_TEXT;
72
+ } else {
73
+ displayText = HINT_TEXT.slice(scrollOffset, scrollOffset + maxHintWidth);
74
+ }
75
+ }
76
+
77
+ const yoloBg = yoloMode ? ENABLED_COLOR : DISABLED_COLOR;
78
+ const yoloFg = yoloMode ? "#ffffff" : "#a0a0b0";
79
+
80
+ return (
81
+ <box width={width} height={1} flexDirection="row" alignItems="center">
82
+ <box width={Math.max(1, width - YOLO_BUTTON_WIDTH - 1)}>
83
+ <text attributes={createTextAttributes({ dim: true })}>{displayText}</text>
84
+ </box>
85
+ <box
86
+ width={YOLO_BUTTON_WIDTH}
87
+ backgroundColor={yoloBg}
88
+ justifyContent="center"
89
+ onMouseUp={(e: MouseEvent) => {
90
+ e.stopPropagation();
91
+ onToggleYolo?.();
92
+ }}
93
+ >
94
+ <text
95
+ fg={yoloFg}
96
+ attributes={createTextAttributes({ bold: true })}
97
+ >
98
+ {" YOLO "}
99
+ </text>
100
+ </box>
101
+ </box>
102
+ );
103
+ }
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Input Box Component
3
+ *
4
+ * Multiline text input with prefix and horizontal borders.
5
+ * Uses OpenTUI <textarea> for editing logic.
6
+ * Supports user message history navigation via ArrowUp/ArrowDown.
7
+ */
8
+
9
+ import { useRef, useMemo, useState, useEffect, useCallback, useImperativeHandle, forwardRef } from "react";
10
+ import { createTextAttributes, type TextareaRenderable, type KeyBinding } from "@opentui/core";
11
+ import type { KeyEvent } from "@opentui/core";
12
+ import type { AgentMode } from "../../agent/mode.js";
13
+ import {
14
+ addToUserHistory,
15
+ getUserHistory,
16
+ } from "../hooks/user-prompt-history.js";
17
+
18
+ const MODE_PREFIX: Record<AgentMode, string> = {
19
+ build: "Build > ",
20
+ ask: "Ask > ",
21
+ plan: "Plan > ",
22
+ };
23
+
24
+ const MODE_COLOR: Record<AgentMode, string> = {
25
+ build: "#4ade80",
26
+ ask: "#fbbf24",
27
+ plan: "#60a5fa",
28
+ };
29
+
30
+ // Ink wave animation phases - pure black/white/gray water ripple
31
+ const INK_WAVE_PHASES = [
32
+ { phase: 0 },
33
+ { phase: 0.25 },
34
+ { phase: 0.5 },
35
+ { phase: 0.75 },
36
+ { phase: 1.0 },
37
+ { phase: 1.25 },
38
+ { phase: 1.5 },
39
+ { phase: 1.75 },
40
+ { phase: 2.0 },
41
+ { phase: 2.25 },
42
+ { phase: 2.5 },
43
+ { phase: 2.75 },
44
+ { phase: 3.0 },
45
+ { phase: 3.25 },
46
+ { phase: 3.5 },
47
+ { phase: 3.75 },
48
+ ];
49
+
50
+ export interface InputBoxHandle {
51
+ clearInput: () => void;
52
+ isInputEmpty: () => boolean;
53
+ }
54
+
55
+ interface InputBoxProps {
56
+ onSubmit: (value: string) => void;
57
+ onQueueSubmit?: (value: string) => void;
58
+ onSlashEmpty?: () => void;
59
+ focused?: boolean;
60
+ disabled?: boolean;
61
+ width?: number;
62
+ mode?: AgentMode;
63
+ isBusy?: boolean;
64
+ onModeSwitch?: () => void;
65
+ }
66
+
67
+ export const InputBox = forwardRef<InputBoxHandle, InputBoxProps>(function InputBox({
68
+ onSubmit,
69
+ onQueueSubmit,
70
+ onSlashEmpty,
71
+ focused = true,
72
+ disabled = false,
73
+ width,
74
+ mode = "build",
75
+ isBusy = false,
76
+ onModeSwitch,
77
+ }: InputBoxProps, ref) {
78
+ const textareaRef = useRef<TextareaRenderable | null>(null);
79
+
80
+ // History navigation state
81
+ // historyIndex: -1 means not browsing history, 0 means viewing the first history item
82
+ const [historyIndex, setHistoryIndex] = useState(-1);
83
+ // savedInput: preserves user's original input when they start browsing history
84
+ const [savedInput, setSavedInput] = useState("");
85
+
86
+ const getText = () => textareaRef.current?.editBuffer.getText() ?? "";
87
+
88
+ // Expose methods to parent via ref
89
+ useImperativeHandle(ref, () => ({
90
+ clearInput: () => {
91
+ textareaRef.current?.editBuffer.replaceText("");
92
+ setHistoryIndex(-1);
93
+ setSavedInput("");
94
+ },
95
+ isInputEmpty: () => getText().trim() === "",
96
+ }), []);
97
+
98
+ // Navigate to previous history item (ArrowUp) - replaces input text
99
+ const navigateToPreviousHistory = useCallback(() => {
100
+ const history = getUserHistory();
101
+ if (history.length === 0) return;
102
+
103
+ // If not already browsing history, save current input and start browsing
104
+ if (historyIndex === -1) {
105
+ setSavedInput(getText());
106
+ setHistoryIndex(0);
107
+ textareaRef.current?.editBuffer.replaceText(history[0]!);
108
+ return;
109
+ }
110
+
111
+ // Move to the next older entry
112
+ const nextIndex = historyIndex + 1;
113
+ if (nextIndex < history.length) {
114
+ setHistoryIndex(nextIndex);
115
+ textareaRef.current?.editBuffer.replaceText(history[nextIndex]!);
116
+ }
117
+ }, [historyIndex]);
118
+
119
+ // Navigate to next history item (ArrowDown) - replaces input text
120
+ const navigateToNextHistory = useCallback(() => {
121
+ const history = getUserHistory();
122
+
123
+ // If not browsing history, nothing to do
124
+ if (historyIndex === -1) return;
125
+
126
+ // Move to the next newer entry (decrement index)
127
+ const nextIndex = historyIndex - 1;
128
+
129
+ if (nextIndex >= 0) {
130
+ setHistoryIndex(nextIndex);
131
+ textareaRef.current?.editBuffer.replaceText(history[nextIndex]!);
132
+ } else {
133
+ // Reached past the beginning, restore saved input
134
+ setHistoryIndex(-1);
135
+ textareaRef.current?.editBuffer.replaceText(savedInput);
136
+ setSavedInput("");
137
+ }
138
+ }, [historyIndex, savedInput]);
139
+
140
+ const handleSubmit = () => {
141
+ const text = getText();
142
+ if (text.trim()) {
143
+ // Add to history before submitting
144
+ addToUserHistory(text);
145
+ onSubmit(text);
146
+ textareaRef.current?.editBuffer.replaceText("");
147
+ // Reset history navigation state
148
+ setHistoryIndex(-1);
149
+ setSavedInput("");
150
+ }
151
+ };
152
+
153
+ const handleKeyDown = (event: KeyEvent) => {
154
+ // Handle ArrowUp for history navigation
155
+ if (event.name === "up" && !event.ctrl && !event.meta && !event.option) {
156
+ const history = getUserHistory();
157
+ if (history.length > 0) {
158
+ const editBuffer = textareaRef.current?.editBuffer;
159
+ if (editBuffer) {
160
+ const cursorPos = editBuffer.getCursorPosition();
161
+ // If cursor is on the first line, switch to previous history item
162
+ if (cursorPos.row <= 0) {
163
+ event.preventDefault();
164
+ event.stopPropagation();
165
+ navigateToPreviousHistory();
166
+ return;
167
+ }
168
+ // Otherwise, let textarea handle natural line navigation
169
+ } else {
170
+ // Fallback: if we can't get cursor position, navigate history anyway
171
+ event.preventDefault();
172
+ event.stopPropagation();
173
+ navigateToPreviousHistory();
174
+ return;
175
+ }
176
+ }
177
+ }
178
+
179
+ // Handle ArrowDown for history navigation
180
+ if (event.name === "down" && !event.ctrl && !event.meta && !event.option) {
181
+ // Only navigate to next history when cursor is on the last line
182
+ if (historyIndex !== -1) {
183
+ const editBuffer = textareaRef.current?.editBuffer;
184
+ if (editBuffer) {
185
+ const cursorPos = editBuffer.getCursorPosition();
186
+ const lineCount = editBuffer.getLineCount();
187
+ // If cursor is on the last line, switch to next history item
188
+ if (cursorPos.row >= lineCount - 1) {
189
+ event.preventDefault();
190
+ event.stopPropagation();
191
+ navigateToNextHistory();
192
+ return;
193
+ }
194
+ // Otherwise, let textarea handle natural line navigation
195
+ }
196
+ }
197
+ }
198
+
199
+ if (event.name === "tab" && event.shift && onModeSwitch) {
200
+ event.preventDefault();
201
+ event.stopPropagation();
202
+ onModeSwitch();
203
+ return;
204
+ }
205
+ if (event.name === "/" && getText() === "" && onSlashEmpty) {
206
+ event.preventDefault();
207
+ event.stopPropagation();
208
+ onSlashEmpty();
209
+ return;
210
+ }
211
+ if (event.name === "return" && event.ctrl && onQueueSubmit) {
212
+ event.preventDefault();
213
+ event.stopPropagation();
214
+ const text = getText();
215
+ if (text.trim()) {
216
+ addToUserHistory(text);
217
+ onQueueSubmit(text);
218
+ textareaRef.current?.editBuffer.replaceText("");
219
+ setHistoryIndex(-1);
220
+ setSavedInput("");
221
+ }
222
+ }
223
+ };
224
+
225
+ const keyBindings = useMemo<KeyBinding[]>(
226
+ () => [
227
+ { name: "return", action: "submit" },
228
+ { name: "return", shift: true, action: "newline" },
229
+ ],
230
+ []
231
+ );
232
+
233
+ // Ink wave animation state - phase index for ripple effect
234
+ const [wavePhase, setWavePhase] = useState(0);
235
+
236
+ // Animate ink wave effect when busy - elegant ripple
237
+ useEffect(() => {
238
+ if (!isBusy) return;
239
+ const interval = setInterval(() => {
240
+ setWavePhase((p) => (p + 1) % INK_WAVE_PHASES.length);
241
+ }, 150); // 150ms for smooth wave
242
+ return () => clearInterval(interval);
243
+ }, [isBusy]);
244
+
245
+ // Generate ink wave characters - pure black/white/gray elegant wave
246
+ const getInkWaveChars = useCallback(() => {
247
+ const totalWidth = width ?? 80;
248
+ const phaseData = INK_WAVE_PHASES[wavePhase];
249
+ if (!phaseData) return [];
250
+
251
+ const p = phaseData.phase;
252
+ const chars: Array<{ char: string; color: string }> = [];
253
+
254
+ for (let i = 0; i < totalWidth; i++) {
255
+ const wavelength = 25;
256
+ const waveNum = (2 * Math.PI) / wavelength;
257
+ const omega = 1.5;
258
+
259
+ // Left source vibration: complex and organic
260
+ const leftAmp1 = Math.sin(p * 3.7) * 0.3;
261
+ const leftAmp2 = Math.sin(p * 5.1 + 0.9) * 0.2;
262
+ const leftAmp3 = Math.sin(p * 2.3 + 1.5) * 0.15;
263
+ const leftAmp4 = Math.sin(p * 7.3 + 2.3) * 0.1;
264
+ const leftAmplitude = 0.4 + leftAmp1 + leftAmp2 + leftAmp3 + leftAmp4;
265
+ const leftDecay = Math.exp(-i / 35);
266
+ const leftWave = Math.sin(waveNum * i - omega * p) * leftAmplitude * leftDecay;
267
+
268
+ // Right source vibration: different organic pattern
269
+ const rightAmp1 = Math.sin(p * 4.1 + 1.1) * 0.3;
270
+ const rightAmp2 = Math.sin(p * 5.7 + 2.3) * 0.2;
271
+ const rightAmp3 = Math.sin(p * 2.9 + 0.8) * 0.15;
272
+ const rightAmp4 = Math.sin(p * 6.5 + 3.1) * 0.1;
273
+ const rightAmplitude = 0.4 + rightAmp1 + rightAmp2 + rightAmp3 + rightAmp4;
274
+ const distFromRight = totalWidth - 1 - i;
275
+ const rightDecay = Math.exp(-distFromRight / 35);
276
+ const rightWave = Math.sin(waveNum * distFromRight - omega * p + Math.PI) * rightAmplitude * rightDecay;
277
+
278
+ // Two waves interfere
279
+ const combined = (leftWave + rightWave) * 0.5 + 0.5;
280
+
281
+ // Map to hex gray color (40-200 range)
282
+ const gray = Math.round(40 + Math.max(0, Math.min(1, combined)) * 160);
283
+ const color = `#${gray.toString(16).padStart(2, '0')}${gray.toString(16).padStart(2, '0')}${gray.toString(16).padStart(2, '0')}`;
284
+
285
+ // Character selection - thicker at peaks
286
+ let char = "─";
287
+ if (combined > 0.85) {
288
+ char = "━";
289
+ } else if (combined > 0.7 && Math.sin(waveNum * i - omega * p) > 0.3) {
290
+ char = "~";
291
+ }
292
+
293
+ chars.push({ char, color });
294
+ }
295
+ return chars;
296
+ }, [wavePhase, width]);
297
+
298
+ // Get border color for bottom only
299
+ const getBottomBorderColor = () => {
300
+ if (disabled) return "#333333";
301
+ return "gray";
302
+ };
303
+
304
+ const inputWidth = Math.max(1, (width ?? 80) - 2);
305
+ const inkWaveChars = isBusy ? getInkWaveChars() : null;
306
+
307
+ return (
308
+ <box
309
+ width={width}
310
+ height={5}
311
+ flexDirection="column"
312
+ border={["bottom"]}
313
+ borderStyle="single"
314
+ borderColor={getBottomBorderColor()}
315
+ paddingX={1}
316
+ overflow="hidden"
317
+ >
318
+ {/* Custom ink wave top border - 水墨风格波纹 */}
319
+ <box width={width} height={1} flexDirection="row">
320
+ {inkWaveChars ? (
321
+ inkWaveChars.map((item, i) => (
322
+ <text key={i} fg={item.color}>{item.char}</text>
323
+ ))
324
+ ) : (
325
+ // Static gray line when not busy
326
+ Array.from({ length: inputWidth }).map((_, i) => (
327
+ <text key={i} fg="gray">─</text>
328
+ ))
329
+ )}
330
+ </box>
331
+ <box flexDirection="row" height={3}>
332
+ <box marginRight={1} flexShrink={0}>
333
+ <text fg={MODE_COLOR[mode]} attributes={createTextAttributes({ bold: true })}>
334
+ {MODE_PREFIX[mode]}
335
+ </text>
336
+ </box>
337
+ <box flexGrow={1} height={3}>
338
+ <textarea
339
+ ref={textareaRef}
340
+ focused={focused}
341
+ showCursor={true}
342
+ height={3}
343
+ width={Math.max(1, (width ?? 80) - MODE_PREFIX[mode].length - 2)}
344
+ onSubmit={handleSubmit}
345
+ onKeyDown={handleKeyDown}
346
+ keyBindings={keyBindings}
347
+ />
348
+ </box>
349
+ </box>
350
+ </box>
351
+ );
352
+ });