ei-tui 0.1.3
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 +21 -0
- package/README.md +170 -0
- package/package.json +63 -0
- package/src/README.md +96 -0
- package/src/cli/README.md +47 -0
- package/src/cli/commands/facts.ts +25 -0
- package/src/cli/commands/people.ts +25 -0
- package/src/cli/commands/quotes.ts +19 -0
- package/src/cli/commands/topics.ts +25 -0
- package/src/cli/commands/traits.ts +25 -0
- package/src/cli/retrieval.ts +269 -0
- package/src/cli.ts +176 -0
- package/src/core/AGENTS.md +104 -0
- package/src/core/embedding-service.ts +241 -0
- package/src/core/handlers/index.ts +1057 -0
- package/src/core/index.ts +4 -0
- package/src/core/llm-client.ts +265 -0
- package/src/core/model-context-windows.ts +49 -0
- package/src/core/orchestrators/ceremony.ts +500 -0
- package/src/core/orchestrators/extraction-chunker.ts +138 -0
- package/src/core/orchestrators/human-extraction.ts +457 -0
- package/src/core/orchestrators/index.ts +28 -0
- package/src/core/orchestrators/persona-generation.ts +76 -0
- package/src/core/orchestrators/persona-topics.ts +117 -0
- package/src/core/personas/index.ts +5 -0
- package/src/core/personas/opencode-agent.ts +81 -0
- package/src/core/processor.ts +1413 -0
- package/src/core/queue-processor.ts +197 -0
- package/src/core/state/checkpoints.ts +68 -0
- package/src/core/state/human.ts +176 -0
- package/src/core/state/index.ts +5 -0
- package/src/core/state/personas.ts +217 -0
- package/src/core/state/queue.ts +144 -0
- package/src/core/state-manager.ts +347 -0
- package/src/core/types.ts +421 -0
- package/src/core/utils/decay.ts +33 -0
- package/src/index.ts +1 -0
- package/src/integrations/opencode/importer.ts +896 -0
- package/src/integrations/opencode/index.ts +16 -0
- package/src/integrations/opencode/json-reader.ts +304 -0
- package/src/integrations/opencode/reader-factory.ts +35 -0
- package/src/integrations/opencode/sqlite-reader.ts +189 -0
- package/src/integrations/opencode/types.ts +244 -0
- package/src/prompts/AGENTS.md +62 -0
- package/src/prompts/ceremony/description-check.ts +47 -0
- package/src/prompts/ceremony/expire.ts +30 -0
- package/src/prompts/ceremony/explore.ts +60 -0
- package/src/prompts/ceremony/index.ts +11 -0
- package/src/prompts/ceremony/types.ts +42 -0
- package/src/prompts/generation/descriptions.ts +91 -0
- package/src/prompts/generation/index.ts +15 -0
- package/src/prompts/generation/persona.ts +155 -0
- package/src/prompts/generation/seeds.ts +31 -0
- package/src/prompts/generation/types.ts +47 -0
- package/src/prompts/heartbeat/check.ts +179 -0
- package/src/prompts/heartbeat/ei.ts +208 -0
- package/src/prompts/heartbeat/index.ts +15 -0
- package/src/prompts/heartbeat/types.ts +70 -0
- package/src/prompts/human/fact-scan.ts +152 -0
- package/src/prompts/human/index.ts +32 -0
- package/src/prompts/human/item-match.ts +74 -0
- package/src/prompts/human/item-update.ts +322 -0
- package/src/prompts/human/person-scan.ts +115 -0
- package/src/prompts/human/topic-scan.ts +135 -0
- package/src/prompts/human/trait-scan.ts +115 -0
- package/src/prompts/human/types.ts +127 -0
- package/src/prompts/index.ts +90 -0
- package/src/prompts/message-utils.ts +39 -0
- package/src/prompts/persona/index.ts +16 -0
- package/src/prompts/persona/topics-match.ts +69 -0
- package/src/prompts/persona/topics-scan.ts +98 -0
- package/src/prompts/persona/topics-update.ts +157 -0
- package/src/prompts/persona/traits.ts +117 -0
- package/src/prompts/persona/types.ts +74 -0
- package/src/prompts/response/index.ts +147 -0
- package/src/prompts/response/sections.ts +355 -0
- package/src/prompts/response/types.ts +38 -0
- package/src/prompts/validation/ei.ts +93 -0
- package/src/prompts/validation/index.ts +6 -0
- package/src/prompts/validation/types.ts +22 -0
- package/src/storage/crypto.ts +96 -0
- package/src/storage/index.ts +5 -0
- package/src/storage/interface.ts +9 -0
- package/src/storage/local.ts +79 -0
- package/src/storage/merge.ts +69 -0
- package/src/storage/remote.ts +145 -0
- package/src/templates/welcome.ts +91 -0
- package/tui/README.md +62 -0
- package/tui/bunfig.toml +4 -0
- package/tui/src/app.tsx +55 -0
- package/tui/src/commands/archive.tsx +93 -0
- package/tui/src/commands/context.tsx +124 -0
- package/tui/src/commands/delete.tsx +71 -0
- package/tui/src/commands/details.tsx +41 -0
- package/tui/src/commands/editor.tsx +46 -0
- package/tui/src/commands/help.tsx +12 -0
- package/tui/src/commands/me.tsx +145 -0
- package/tui/src/commands/model.ts +47 -0
- package/tui/src/commands/new.ts +31 -0
- package/tui/src/commands/pause.ts +46 -0
- package/tui/src/commands/persona.tsx +58 -0
- package/tui/src/commands/provider.tsx +124 -0
- package/tui/src/commands/quit.ts +22 -0
- package/tui/src/commands/quotes.tsx +172 -0
- package/tui/src/commands/registry.test.ts +137 -0
- package/tui/src/commands/registry.ts +130 -0
- package/tui/src/commands/resume.ts +39 -0
- package/tui/src/commands/setsync.tsx +43 -0
- package/tui/src/commands/settings.tsx +83 -0
- package/tui/src/components/ConfirmOverlay.tsx +51 -0
- package/tui/src/components/ConflictOverlay.tsx +78 -0
- package/tui/src/components/HelpOverlay.tsx +69 -0
- package/tui/src/components/Layout.tsx +24 -0
- package/tui/src/components/MessageList.tsx +174 -0
- package/tui/src/components/PersonaListOverlay.tsx +186 -0
- package/tui/src/components/PromptInput.tsx +145 -0
- package/tui/src/components/ProviderListOverlay.tsx +208 -0
- package/tui/src/components/QuotesOverlay.tsx +157 -0
- package/tui/src/components/Sidebar.tsx +95 -0
- package/tui/src/components/StatusBar.tsx +77 -0
- package/tui/src/components/WelcomeOverlay.tsx +73 -0
- package/tui/src/context/ei.tsx +623 -0
- package/tui/src/context/keyboard.tsx +164 -0
- package/tui/src/context/overlay.tsx +53 -0
- package/tui/src/index.tsx +8 -0
- package/tui/src/storage/file.ts +185 -0
- package/tui/src/util/duration.ts +32 -0
- package/tui/src/util/editor.ts +188 -0
- package/tui/src/util/logger.ts +109 -0
- package/tui/src/util/persona-editor.tsx +181 -0
- package/tui/src/util/provider-editor.tsx +168 -0
- package/tui/src/util/syntax.ts +35 -0
- package/tui/src/util/yaml-serializers.ts +755 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Command } from "./registry.js";
|
|
2
|
+
import { spawnEditor } from "../util/editor.js";
|
|
3
|
+
import { settingsToYAML, settingsFromYAML, validateModelProvider } from "../util/yaml-serializers.js";
|
|
4
|
+
import { logger } from "../util/logger.js";
|
|
5
|
+
import { ConfirmOverlay } from "../components/ConfirmOverlay.js";
|
|
6
|
+
|
|
7
|
+
export const settingsCommand: Command = {
|
|
8
|
+
name: "settings",
|
|
9
|
+
aliases: ["set"],
|
|
10
|
+
description: "Edit your settings in $EDITOR",
|
|
11
|
+
usage: "/settings",
|
|
12
|
+
|
|
13
|
+
async execute(_args, ctx) {
|
|
14
|
+
const human = await ctx.ei.getHuman();
|
|
15
|
+
let yamlContent = settingsToYAML(human.settings);
|
|
16
|
+
let editorIteration = 0;
|
|
17
|
+
|
|
18
|
+
while (true) {
|
|
19
|
+
editorIteration++;
|
|
20
|
+
logger.debug("[settings] starting editor iteration", { iteration: editorIteration });
|
|
21
|
+
|
|
22
|
+
const result = await spawnEditor({
|
|
23
|
+
initialContent: yamlContent,
|
|
24
|
+
filename: "settings.yaml",
|
|
25
|
+
renderer: ctx.renderer,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (result.aborted) {
|
|
29
|
+
ctx.showNotification("Editor cancelled", "info");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!result.success) {
|
|
34
|
+
ctx.showNotification("Editor failed to open", "error");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (result.content === null) {
|
|
39
|
+
ctx.showNotification("No changes made", "info");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const newSettings = settingsFromYAML(result.content, human.settings);
|
|
45
|
+
// Validate provider name in default_model (case-insensitive match + auto-correct)
|
|
46
|
+
const llmAccounts = human.settings?.accounts?.filter(a => a.type === "llm") ?? [];
|
|
47
|
+
newSettings.default_model = validateModelProvider(newSettings.default_model, llmAccounts);
|
|
48
|
+
await ctx.ei.updateSettings(newSettings);
|
|
49
|
+
ctx.showNotification("Settings updated", "info");
|
|
50
|
+
return;
|
|
51
|
+
|
|
52
|
+
} catch (parseError) {
|
|
53
|
+
const errorMsg = parseError instanceof Error ? parseError.message : String(parseError);
|
|
54
|
+
logger.debug("[settings] YAML parse error, prompting for re-edit", { iteration: editorIteration, error: errorMsg });
|
|
55
|
+
|
|
56
|
+
const shouldReEdit = await new Promise<boolean>((resolve) => {
|
|
57
|
+
ctx.showOverlay((hideOverlay) => (
|
|
58
|
+
<ConfirmOverlay
|
|
59
|
+
message={`YAML parse error:\n${errorMsg}\n\nRe-edit?`}
|
|
60
|
+
onConfirm={() => {
|
|
61
|
+
hideOverlay();
|
|
62
|
+
resolve(true);
|
|
63
|
+
}}
|
|
64
|
+
onCancel={() => {
|
|
65
|
+
hideOverlay();
|
|
66
|
+
resolve(false);
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (shouldReEdit) {
|
|
73
|
+
yamlContent = result.content;
|
|
74
|
+
await new Promise(r => setTimeout(r, 50));
|
|
75
|
+
continue;
|
|
76
|
+
} else {
|
|
77
|
+
ctx.showNotification("Changes discarded", "info");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useKeyboard } from "@opentui/solid";
|
|
2
|
+
|
|
3
|
+
interface ConfirmOverlayProps {
|
|
4
|
+
message: string;
|
|
5
|
+
onConfirm: () => void;
|
|
6
|
+
onCancel: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function ConfirmOverlay(props: ConfirmOverlayProps) {
|
|
10
|
+
useKeyboard((event) => {
|
|
11
|
+
event.preventDefault();
|
|
12
|
+
|
|
13
|
+
const key = event.name.toLowerCase();
|
|
14
|
+
|
|
15
|
+
if (key === 'y') {
|
|
16
|
+
props.onConfirm();
|
|
17
|
+
} else if (key === 'n' || key === 'escape') {
|
|
18
|
+
props.onCancel();
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<box
|
|
24
|
+
position="absolute"
|
|
25
|
+
width="100%"
|
|
26
|
+
height="100%"
|
|
27
|
+
left={0}
|
|
28
|
+
top={0}
|
|
29
|
+
backgroundColor="#000000"
|
|
30
|
+
alignItems="center"
|
|
31
|
+
justifyContent="center"
|
|
32
|
+
>
|
|
33
|
+
<box
|
|
34
|
+
width={50}
|
|
35
|
+
backgroundColor="#1a1a2e"
|
|
36
|
+
borderStyle="single"
|
|
37
|
+
borderColor="#586e75"
|
|
38
|
+
padding={2}
|
|
39
|
+
flexDirection="column"
|
|
40
|
+
>
|
|
41
|
+
<text fg="#eee8d5">
|
|
42
|
+
{props.message}
|
|
43
|
+
</text>
|
|
44
|
+
<text> </text>
|
|
45
|
+
<text fg="#586e75">
|
|
46
|
+
(y/N)
|
|
47
|
+
</text>
|
|
48
|
+
</box>
|
|
49
|
+
</box>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useKeyboard } from "@opentui/solid";
|
|
2
|
+
import type { StateConflictResolution } from "../../../src/core/types.js";
|
|
3
|
+
|
|
4
|
+
interface ConflictOverlayProps {
|
|
5
|
+
localTimestamp: Date;
|
|
6
|
+
remoteTimestamp: Date;
|
|
7
|
+
onResolve: (resolution: StateConflictResolution) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function formatTimestamp(date: Date): string {
|
|
11
|
+
const y = date.getFullYear();
|
|
12
|
+
const mo = (date.getMonth() + 1).toString().padStart(2, "0");
|
|
13
|
+
const d = date.getDate().toString().padStart(2, "0");
|
|
14
|
+
const h = date.getHours().toString().padStart(2, "0");
|
|
15
|
+
const m = date.getMinutes().toString().padStart(2, "0");
|
|
16
|
+
return `${y}-${mo}-${d} ${h}:${m}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ConflictOverlay(props: ConflictOverlayProps) {
|
|
20
|
+
useKeyboard((event) => {
|
|
21
|
+
event.preventDefault();
|
|
22
|
+
|
|
23
|
+
const key = event.name.toLowerCase();
|
|
24
|
+
|
|
25
|
+
if (key === "l") {
|
|
26
|
+
props.onResolve("local");
|
|
27
|
+
} else if (key === "s") {
|
|
28
|
+
props.onResolve("server");
|
|
29
|
+
} else if (key === "y") {
|
|
30
|
+
props.onResolve("yolo");
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<box
|
|
36
|
+
position="absolute"
|
|
37
|
+
width="100%"
|
|
38
|
+
height="100%"
|
|
39
|
+
left={0}
|
|
40
|
+
top={0}
|
|
41
|
+
backgroundColor="#000000"
|
|
42
|
+
alignItems="center"
|
|
43
|
+
justifyContent="center"
|
|
44
|
+
>
|
|
45
|
+
<box
|
|
46
|
+
width={60}
|
|
47
|
+
backgroundColor="#1a1a2e"
|
|
48
|
+
borderStyle="single"
|
|
49
|
+
borderColor="#586e75"
|
|
50
|
+
padding={2}
|
|
51
|
+
flexDirection="column"
|
|
52
|
+
>
|
|
53
|
+
<text fg="#dc322f">
|
|
54
|
+
State Conflict Detected
|
|
55
|
+
</text>
|
|
56
|
+
<text> </text>
|
|
57
|
+
<text fg="#93a1a1">
|
|
58
|
+
Both local and server state exist.
|
|
59
|
+
</text>
|
|
60
|
+
<text> </text>
|
|
61
|
+
<text fg="#eee8d5">
|
|
62
|
+
{` Local: ${formatTimestamp(props.localTimestamp)}`}
|
|
63
|
+
</text>
|
|
64
|
+
<text fg="#eee8d5">
|
|
65
|
+
{` Server: ${formatTimestamp(props.remoteTimestamp)}`}
|
|
66
|
+
</text>
|
|
67
|
+
<text> </text>
|
|
68
|
+
<text fg="#b58900">
|
|
69
|
+
[L] Keep Local [S] Use Server [Y] Yolo Merge
|
|
70
|
+
</text>
|
|
71
|
+
<text> </text>
|
|
72
|
+
<text fg="#586e75">
|
|
73
|
+
Yolo Merge combines both — safe for most cases
|
|
74
|
+
</text>
|
|
75
|
+
</box>
|
|
76
|
+
</box>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useKeyboard } from "@opentui/solid";
|
|
2
|
+
import { For } from "solid-js";
|
|
3
|
+
import { getAllCommands } from "../commands/registry";
|
|
4
|
+
|
|
5
|
+
interface HelpOverlayProps {
|
|
6
|
+
onDismiss: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function HelpOverlay(props: HelpOverlayProps) {
|
|
10
|
+
useKeyboard((event) => {
|
|
11
|
+
event.preventDefault();
|
|
12
|
+
props.onDismiss();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const commands = getAllCommands();
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<box
|
|
19
|
+
position="absolute"
|
|
20
|
+
width="100%"
|
|
21
|
+
height="100%"
|
|
22
|
+
left={0}
|
|
23
|
+
top={0}
|
|
24
|
+
backgroundColor="#000000"
|
|
25
|
+
alignItems="center"
|
|
26
|
+
justifyContent="center"
|
|
27
|
+
>
|
|
28
|
+
<box
|
|
29
|
+
width={60}
|
|
30
|
+
backgroundColor="#1a1a2e"
|
|
31
|
+
borderStyle="single"
|
|
32
|
+
borderColor="#586e75"
|
|
33
|
+
padding={2}
|
|
34
|
+
flexDirection="column"
|
|
35
|
+
>
|
|
36
|
+
|
|
37
|
+
<text fg="#eee8d5">
|
|
38
|
+
Commands:
|
|
39
|
+
</text>
|
|
40
|
+
<For each={commands.sort()}>
|
|
41
|
+
{(cmd) => (
|
|
42
|
+
<text fg="#93a1a1">
|
|
43
|
+
/{cmd.name} - {cmd.description}
|
|
44
|
+
</text>
|
|
45
|
+
)}
|
|
46
|
+
</For>
|
|
47
|
+
<text> </text>
|
|
48
|
+
|
|
49
|
+
<text fg="#eee8d5">
|
|
50
|
+
Keybindings:
|
|
51
|
+
</text>
|
|
52
|
+
<text fg="#93a1a1">Escape - Abort operation / Resume queue</text>
|
|
53
|
+
<text fg="#93a1a1">Ctrl+C - Clear input / Exit</text>
|
|
54
|
+
<text fg="#93a1a1">Ctrl+B - Toggle sidebar</text>
|
|
55
|
+
<text fg="#93a1a1">Ctrl+E - Edit in $EDITOR</text>
|
|
56
|
+
<text fg="#93a1a1">PageUp/Down - Scroll messages</text>
|
|
57
|
+
<text> </text>
|
|
58
|
+
|
|
59
|
+
<text fg="#586e75">
|
|
60
|
+
Press any key to dismiss
|
|
61
|
+
</text>
|
|
62
|
+
<text> </text>
|
|
63
|
+
<text fg="#2a2a3e">
|
|
64
|
+
Ei - 永 (ei) - eternal
|
|
65
|
+
</text>
|
|
66
|
+
</box>
|
|
67
|
+
</box>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { JSX, Show } from "solid-js";
|
|
2
|
+
import { useKeyboardNav } from "../context/keyboard";
|
|
3
|
+
|
|
4
|
+
interface LayoutProps {
|
|
5
|
+
sidebar: JSX.Element;
|
|
6
|
+
messages: JSX.Element;
|
|
7
|
+
input: JSX.Element;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Layout(props: LayoutProps) {
|
|
11
|
+
const { sidebarVisible } = useKeyboardNav();
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<box flexDirection="row" width="100%" height="100%">
|
|
15
|
+
<Show when={sidebarVisible()}>
|
|
16
|
+
{props.sidebar}
|
|
17
|
+
</Show>
|
|
18
|
+
<box flexDirection="column" flexGrow={1}>
|
|
19
|
+
{props.messages}
|
|
20
|
+
{props.input}
|
|
21
|
+
</box>
|
|
22
|
+
</box>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { For, Show, createMemo, createSignal, createEffect, on } from "solid-js";
|
|
2
|
+
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
|
|
3
|
+
import { useEi } from "../context/ei.js";
|
|
4
|
+
import { useKeyboardNav } from "../context/keyboard.js";
|
|
5
|
+
import { logger } from "../util/logger.js";
|
|
6
|
+
import { solarizedDarkSyntax } from "../util/syntax.js";
|
|
7
|
+
import type { Quote, Message } from "../../../src/core/types.js";
|
|
8
|
+
|
|
9
|
+
interface MessageWithQuotes extends Message {
|
|
10
|
+
_quotes: Quote[];
|
|
11
|
+
_quoteIndex: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatTime(timestamp: string): string {
|
|
15
|
+
const date = new Date(timestamp);
|
|
16
|
+
const hours = date.getHours().toString().padStart(2, "0");
|
|
17
|
+
const minutes = date.getMinutes().toString().padStart(2, "0");
|
|
18
|
+
return `${hours}:${minutes}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function insertQuoteMarkers(content: string, quotes: Quote[]): string {
|
|
22
|
+
const validQuotes = quotes
|
|
23
|
+
.filter(q => q.end !== null && q.end !== undefined)
|
|
24
|
+
.sort((a, b) => b.end! - a.end!);
|
|
25
|
+
|
|
26
|
+
let result = content;
|
|
27
|
+
for (const quote of validQuotes) {
|
|
28
|
+
let insertPos = quote.end!;
|
|
29
|
+
if (insertPos >= 0 && insertPos <= result.length) {
|
|
30
|
+
while (insertPos > 0 && (result[insertPos - 1] === '\n' || result[insertPos - 1] === ' ')) {
|
|
31
|
+
insertPos--;
|
|
32
|
+
}
|
|
33
|
+
result = result.slice(0, insertPos) + "⁺" + result.slice(insertPos);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let instanceId = 0;
|
|
40
|
+
|
|
41
|
+
export function MessageList() {
|
|
42
|
+
const myId = ++instanceId;
|
|
43
|
+
logger.info(`MessageList instance ${myId} MOUNTED`);
|
|
44
|
+
|
|
45
|
+
const { messages, activePersonaId, personas, activeContextBoundary, getQuotes, quotesVersion } = useEi();
|
|
46
|
+
const { focusedPanel, registerMessageScroll } = useKeyboardNav();
|
|
47
|
+
|
|
48
|
+
const isFocused = () => focusedPanel() === "messages";
|
|
49
|
+
|
|
50
|
+
const [allQuotes, setAllQuotes] = createSignal<Quote[]>([]);
|
|
51
|
+
|
|
52
|
+
createEffect(on(() => [messages(), quotesVersion()], () => {
|
|
53
|
+
void getQuotes().then(setAllQuotes);
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
const messagesWithQuotes = createMemo<MessageWithQuotes[]>(() => {
|
|
57
|
+
const quotesByMessage = new Map<string, Quote[]>();
|
|
58
|
+
for (const quote of allQuotes()) {
|
|
59
|
+
if (quote.message_id) {
|
|
60
|
+
const existing = quotesByMessage.get(quote.message_id) ?? [];
|
|
61
|
+
existing.push(quote);
|
|
62
|
+
quotesByMessage.set(quote.message_id, existing);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return messages().map((msg, idx) => {
|
|
67
|
+
const quotes = quotesByMessage.get(msg.id) ?? [];
|
|
68
|
+
return {
|
|
69
|
+
...msg,
|
|
70
|
+
_quotes: quotes,
|
|
71
|
+
_quoteIndex: idx + 1,
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const handleScrollboxRef = (scrollbox: ScrollBoxRenderable) => {
|
|
77
|
+
registerMessageScroll(scrollbox);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const boundaryIsActive = createMemo(() => {
|
|
81
|
+
const boundary = activeContextBoundary();
|
|
82
|
+
const msgs = messages();
|
|
83
|
+
const lastMessage = msgs[msgs.length - 1];
|
|
84
|
+
return boundary ? (!lastMessage || boundary > lastMessage.timestamp) : false;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<box
|
|
89
|
+
flexGrow={1}
|
|
90
|
+
border={isFocused() ? ["left"] : undefined}
|
|
91
|
+
borderColor={isFocused() ? "#268bd2" : undefined}
|
|
92
|
+
borderStyle="single"
|
|
93
|
+
>
|
|
94
|
+
<Show
|
|
95
|
+
when={messagesWithQuotes().length > 0}
|
|
96
|
+
fallback={
|
|
97
|
+
<box flexGrow={1} padding={1} backgroundColor="#0f1419" justifyContent="center" alignItems="center">
|
|
98
|
+
<text fg="#586e75" content="No messages yet. Start a conversation!" />
|
|
99
|
+
</box>
|
|
100
|
+
}
|
|
101
|
+
>
|
|
102
|
+
<scrollbox
|
|
103
|
+
ref={handleScrollboxRef}
|
|
104
|
+
flexGrow={1}
|
|
105
|
+
padding={1}
|
|
106
|
+
backgroundColor="#0f1419"
|
|
107
|
+
stickyScroll={true}
|
|
108
|
+
stickyStart="bottom"
|
|
109
|
+
>
|
|
110
|
+
<For each={messagesWithQuotes()}>
|
|
111
|
+
{(message, index) => {
|
|
112
|
+
const getDisplayName = () => {
|
|
113
|
+
const persona = personas().find(p => p.id === activePersonaId());
|
|
114
|
+
return persona?.display_name ?? "Ei";
|
|
115
|
+
};
|
|
116
|
+
const speaker = message.role === "human" ? "Human" : getDisplayName();
|
|
117
|
+
const speakerColor = message.role === "human" ? "#2aa198" : "#b58900";
|
|
118
|
+
|
|
119
|
+
const header = () => `${speaker} (${formatTime(message.timestamp)}) [✂️ ${message._quoteIndex}]:`;
|
|
120
|
+
|
|
121
|
+
const displayContent = insertQuoteMarkers(message.content, message._quotes);
|
|
122
|
+
|
|
123
|
+
const showDivider = () => {
|
|
124
|
+
const boundary = activeContextBoundary();
|
|
125
|
+
if (!boundary) return false;
|
|
126
|
+
const i = index();
|
|
127
|
+
if (i === 0) return false;
|
|
128
|
+
const msgs = messagesWithQuotes();
|
|
129
|
+
const prevMsg = msgs[i - 1];
|
|
130
|
+
const result = prevMsg.timestamp < boundary && message.timestamp >= boundary;
|
|
131
|
+
if (result) {
|
|
132
|
+
logger.debug(`showDivider TRUE at index ${i}: prev=${prevMsg.timestamp}, boundary=${boundary}, curr=${message.timestamp}`);
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const showTrailingDivider = () => {
|
|
138
|
+
const msgs = messagesWithQuotes();
|
|
139
|
+
const isLast = index() === msgs.length - 1;
|
|
140
|
+
if (!isLast) return false;
|
|
141
|
+
return boundaryIsActive();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<>
|
|
146
|
+
<box marginTop={1} marginBottom={1} visible={showDivider()}>
|
|
147
|
+
<text fg="#586e75" content="─── New Context ───" />
|
|
148
|
+
</box>
|
|
149
|
+
<box flexDirection="column" marginBottom={1}>
|
|
150
|
+
<text
|
|
151
|
+
fg={speakerColor}
|
|
152
|
+
attributes={TextAttributes.BOLD}
|
|
153
|
+
content={header()}
|
|
154
|
+
/>
|
|
155
|
+
<box marginLeft={2}>
|
|
156
|
+
<markdown
|
|
157
|
+
content={displayContent}
|
|
158
|
+
syntaxStyle={solarizedDarkSyntax}
|
|
159
|
+
conceal={true}
|
|
160
|
+
/>
|
|
161
|
+
</box>
|
|
162
|
+
</box>
|
|
163
|
+
<box marginTop={1} marginBottom={1} visible={showTrailingDivider()}>
|
|
164
|
+
<text fg="#586e75" content="─── New Context ───" />
|
|
165
|
+
</box>
|
|
166
|
+
</>
|
|
167
|
+
);
|
|
168
|
+
}}
|
|
169
|
+
</For>
|
|
170
|
+
</scrollbox>
|
|
171
|
+
</Show>
|
|
172
|
+
</box>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { useKeyboard } from "@opentui/solid";
|
|
2
|
+
import { For, createSignal, createMemo } from "solid-js";
|
|
3
|
+
import type { KeyEvent } from "@opentui/core";
|
|
4
|
+
import type { PersonaSummary } from "../../../src/core/types.js";
|
|
5
|
+
|
|
6
|
+
interface PersonaListOverlayProps {
|
|
7
|
+
personas: PersonaSummary[];
|
|
8
|
+
activePersonaId: string | null;
|
|
9
|
+
title?: string;
|
|
10
|
+
onSelect: (personaId: string) => void;
|
|
11
|
+
onDismiss: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function PersonaListOverlay(props: PersonaListOverlayProps) {
|
|
15
|
+
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
|
16
|
+
const [filterText, setFilterText] = createSignal("");
|
|
17
|
+
const [filterMode, setFilterMode] = createSignal(false);
|
|
18
|
+
|
|
19
|
+
const filteredPersonas = createMemo(() => {
|
|
20
|
+
const filter = filterText().toLowerCase();
|
|
21
|
+
if (!filter) return props.personas;
|
|
22
|
+
return props.personas.filter((p) =>
|
|
23
|
+
p.display_name.toLowerCase().includes(filter)
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
createMemo(() => {
|
|
28
|
+
const list = filteredPersonas();
|
|
29
|
+
if (selectedIndex() >= list.length) {
|
|
30
|
+
setSelectedIndex(Math.max(0, list.length - 1));
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
useKeyboard((event: KeyEvent) => {
|
|
35
|
+
const key = event.name;
|
|
36
|
+
const listLength = filteredPersonas().length;
|
|
37
|
+
|
|
38
|
+
if (filterMode()) {
|
|
39
|
+
if (key === "escape") {
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
setFilterText("");
|
|
42
|
+
setFilterMode(false);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (key === "backspace") {
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
setFilterText((prev) => prev.slice(0, -1));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (key === "return") {
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
if (listLength > 0) {
|
|
55
|
+
const selected = filteredPersonas()[selectedIndex()];
|
|
56
|
+
props.onSelect(selected.id);
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (key.length === 1 && !event.ctrl && !event.meta) {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
setFilterText((prev) => prev + key);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
if (key === "j" || key === "down") {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
setSelectedIndex((prev) => Math.min(prev + 1, listLength - 1));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (key === "k" || key === "up") {
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (key === "return") {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
if (listLength > 0) {
|
|
82
|
+
const selected = filteredPersonas()[selectedIndex()];
|
|
83
|
+
props.onSelect(selected.id);
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (key === "escape") {
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
props.onDismiss();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (key === "/") {
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
setFilterMode(true);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const truncateDescription = (desc: string | undefined, maxLen: number = 40): string => {
|
|
103
|
+
if (!desc) return "";
|
|
104
|
+
return desc.length > maxLen ? desc.slice(0, maxLen - 3) + "..." : desc;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const title = props.title || "Select Persona";
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<box
|
|
111
|
+
position="absolute"
|
|
112
|
+
width="100%"
|
|
113
|
+
height="100%"
|
|
114
|
+
left={0}
|
|
115
|
+
top={0}
|
|
116
|
+
backgroundColor="#000000"
|
|
117
|
+
alignItems="center"
|
|
118
|
+
justifyContent="center"
|
|
119
|
+
>
|
|
120
|
+
<box
|
|
121
|
+
width={70}
|
|
122
|
+
height="80%"
|
|
123
|
+
backgroundColor="#1a1a2e"
|
|
124
|
+
borderStyle="single"
|
|
125
|
+
borderColor="#586e75"
|
|
126
|
+
padding={2}
|
|
127
|
+
flexDirection="column"
|
|
128
|
+
>
|
|
129
|
+
<text fg="#eee8d5" marginBottom={1}>
|
|
130
|
+
{title}
|
|
131
|
+
</text>
|
|
132
|
+
|
|
133
|
+
<scrollbox height="100%">
|
|
134
|
+
<For each={filteredPersonas()}>
|
|
135
|
+
{(persona, index) => {
|
|
136
|
+
const isActive = () => props.activePersonaId === persona.id;
|
|
137
|
+
const isSelected = () => selectedIndex() === index();
|
|
138
|
+
const description = truncateDescription(persona.short_description);
|
|
139
|
+
const label = () => {
|
|
140
|
+
const prefix = isActive() ? "> " : " ";
|
|
141
|
+
const descText = description ? ` - ${description}` : "";
|
|
142
|
+
return `${prefix}${persona.display_name}${descText}`;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<box
|
|
147
|
+
backgroundColor={
|
|
148
|
+
isSelected()
|
|
149
|
+
? "#2d3748"
|
|
150
|
+
: isActive()
|
|
151
|
+
? "#1f2937"
|
|
152
|
+
: "transparent"
|
|
153
|
+
}
|
|
154
|
+
paddingLeft={1}
|
|
155
|
+
paddingRight={1}
|
|
156
|
+
>
|
|
157
|
+
<text
|
|
158
|
+
fg={
|
|
159
|
+
isSelected()
|
|
160
|
+
? "#eee8d5"
|
|
161
|
+
: isActive()
|
|
162
|
+
? "#93a1a1"
|
|
163
|
+
: "#839496"
|
|
164
|
+
}
|
|
165
|
+
>
|
|
166
|
+
{label()}
|
|
167
|
+
</text>
|
|
168
|
+
</box>
|
|
169
|
+
);
|
|
170
|
+
}}
|
|
171
|
+
</For>
|
|
172
|
+
</scrollbox>
|
|
173
|
+
|
|
174
|
+
<text> </text>
|
|
175
|
+
|
|
176
|
+
{filterMode() ? (
|
|
177
|
+
<text fg="#586e75">Filter: {filterText()}|</text>
|
|
178
|
+
) : (
|
|
179
|
+
<text fg="#586e75">
|
|
180
|
+
j/k: navigate | Enter: select | Esc: cancel | /: filter
|
|
181
|
+
</text>
|
|
182
|
+
)}
|
|
183
|
+
</box>
|
|
184
|
+
</box>
|
|
185
|
+
);
|
|
186
|
+
}
|