@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,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
|
+
}
|