@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.
- package/LICENSE +34 -0
- package/NOTICE +35 -0
- package/README.md +15 -0
- package/bin/koi +12 -0
- package/dist/highlights-eq9cgrbb.scm +604 -0
- package/dist/highlights-ghv9g403.scm +205 -0
- package/dist/highlights-hk7bwhj4.scm +284 -0
- package/dist/highlights-r812a2qc.scm +150 -0
- package/dist/highlights-x6tmsnaa.scm +115 -0
- package/dist/injections-73j83es3.scm +27 -0
- package/dist/main.js +489918 -0
- package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
- package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
- package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
- package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
- package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
- package/package.json +51 -0
- package/src/agent/check-permissions.ts +239 -0
- package/src/agent/hooks/message-utils.ts +305 -0
- package/src/agent/hooks/types.ts +32 -0
- package/src/agent/hooks.ts +1560 -0
- package/src/agent/mode.ts +163 -0
- package/src/agent/monitor-registry.ts +308 -0
- package/src/agent/permission-ui.ts +71 -0
- package/src/agent/plan-ui.ts +74 -0
- package/src/agent/question-ui.ts +58 -0
- package/src/agent/session-fork.ts +299 -0
- package/src/agent/session-snapshots.ts +216 -0
- package/src/agent/session-store.ts +649 -0
- package/src/agent/session-tasks.ts +305 -0
- package/src/agent/session.ts +27 -0
- package/src/agent/subagent-registry.ts +176 -0
- package/src/agent/subagent.ts +194 -0
- package/src/agent/tool-orchestration.ts +55 -0
- package/src/agent/tools.ts +8 -0
- package/src/cli/args.ts +6 -0
- package/src/cli/commands.ts +5 -0
- package/src/commands/skills/index.ts +23 -0
- package/src/config/models.ts +6 -0
- package/src/config/settings.ts +392 -0
- package/src/main.tsx +64 -0
- package/src/services/mcp/client.ts +194 -0
- package/src/services/mcp/config.ts +232 -0
- package/src/services/mcp/connection-manager.ts +258 -0
- package/src/services/mcp/index.ts +80 -0
- package/src/services/mcp/mcp-commands.ts +114 -0
- package/src/services/mcp/stdio-transport.ts +246 -0
- package/src/services/mcp/types.ts +155 -0
- package/src/skills/SkillsMenu.tsx +370 -0
- package/src/skills/bundled/batch.ts +106 -0
- package/src/skills/bundled/debug.ts +86 -0
- package/src/skills/bundled/loremIpsum.ts +101 -0
- package/src/skills/bundled/remember.ts +97 -0
- package/src/skills/bundled/simplify.ts +100 -0
- package/src/skills/bundled/skillify.ts +123 -0
- package/src/skills/bundled/stuck.ts +101 -0
- package/src/skills/bundled/updateConfig.ts +228 -0
- package/src/skills/bundled.ts +46 -0
- package/src/skills/frontmatter.ts +179 -0
- package/src/skills/index.ts +87 -0
- package/src/skills/invoke.ts +231 -0
- package/src/skills/loader.ts +710 -0
- package/src/skills/substitution.ts +169 -0
- package/src/skills/types.ts +201 -0
- package/src/tools/agent.ts +143 -0
- package/src/tools/ask-user-question.ts +46 -0
- package/src/tools/bash.ts +148 -0
- package/src/tools/edit.ts +164 -0
- package/src/tools/glob.ts +102 -0
- package/src/tools/grep.ts +248 -0
- package/src/tools/index.ts +73 -0
- package/src/tools/list-mcp-resources.ts +74 -0
- package/src/tools/ls.ts +85 -0
- package/src/tools/mcp.ts +76 -0
- package/src/tools/monitor.ts +159 -0
- package/src/tools/plan-mode.ts +134 -0
- package/src/tools/read-mcp-resource.ts +79 -0
- package/src/tools/read.ts +137 -0
- package/src/tools/skill.ts +176 -0
- package/src/tools/task.ts +349 -0
- package/src/tools/types.ts +52 -0
- package/src/tools/webfetch-domains.ts +239 -0
- package/src/tools/webfetch.ts +533 -0
- package/src/tools/write.ts +101 -0
- package/src/tui/app.tsx +1178 -0
- package/src/tui/components/chat-panel.tsx +1071 -0
- package/src/tui/components/command-panel.tsx +261 -0
- package/src/tui/components/confirm-modal.tsx +135 -0
- package/src/tui/components/connect-modal.tsx +435 -0
- package/src/tui/components/connecting-modal.tsx +167 -0
- package/src/tui/components/edit-pending-modal.tsx +103 -0
- package/src/tui/components/exit-modal.tsx +131 -0
- package/src/tui/components/fork-modal.tsx +377 -0
- package/src/tui/components/image-preview-modal.tsx +141 -0
- package/src/tui/components/image-utils.ts +128 -0
- package/src/tui/components/info-bar.tsx +103 -0
- package/src/tui/components/input-box.tsx +352 -0
- package/src/tui/components/mcp/MCPSettings.tsx +386 -0
- package/src/tui/components/mcp/index.ts +7 -0
- package/src/tui/components/model-modal.tsx +310 -0
- package/src/tui/components/pending-area.tsx +88 -0
- package/src/tui/components/rename-modal.tsx +119 -0
- package/src/tui/components/session-modal.tsx +233 -0
- package/src/tui/components/side-bar.tsx +349 -0
- package/src/tui/components/tool-output.ts +6 -0
- package/src/tui/hooks/user-prompt-history.ts +114 -0
- package/src/tui/theme.ts +63 -0
- package/src/types/commands.ts +80 -0
- 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
|
+
}
|