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.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/package.json +63 -0
  4. package/src/README.md +96 -0
  5. package/src/cli/README.md +47 -0
  6. package/src/cli/commands/facts.ts +25 -0
  7. package/src/cli/commands/people.ts +25 -0
  8. package/src/cli/commands/quotes.ts +19 -0
  9. package/src/cli/commands/topics.ts +25 -0
  10. package/src/cli/commands/traits.ts +25 -0
  11. package/src/cli/retrieval.ts +269 -0
  12. package/src/cli.ts +176 -0
  13. package/src/core/AGENTS.md +104 -0
  14. package/src/core/embedding-service.ts +241 -0
  15. package/src/core/handlers/index.ts +1057 -0
  16. package/src/core/index.ts +4 -0
  17. package/src/core/llm-client.ts +265 -0
  18. package/src/core/model-context-windows.ts +49 -0
  19. package/src/core/orchestrators/ceremony.ts +500 -0
  20. package/src/core/orchestrators/extraction-chunker.ts +138 -0
  21. package/src/core/orchestrators/human-extraction.ts +457 -0
  22. package/src/core/orchestrators/index.ts +28 -0
  23. package/src/core/orchestrators/persona-generation.ts +76 -0
  24. package/src/core/orchestrators/persona-topics.ts +117 -0
  25. package/src/core/personas/index.ts +5 -0
  26. package/src/core/personas/opencode-agent.ts +81 -0
  27. package/src/core/processor.ts +1413 -0
  28. package/src/core/queue-processor.ts +197 -0
  29. package/src/core/state/checkpoints.ts +68 -0
  30. package/src/core/state/human.ts +176 -0
  31. package/src/core/state/index.ts +5 -0
  32. package/src/core/state/personas.ts +217 -0
  33. package/src/core/state/queue.ts +144 -0
  34. package/src/core/state-manager.ts +347 -0
  35. package/src/core/types.ts +421 -0
  36. package/src/core/utils/decay.ts +33 -0
  37. package/src/index.ts +1 -0
  38. package/src/integrations/opencode/importer.ts +896 -0
  39. package/src/integrations/opencode/index.ts +16 -0
  40. package/src/integrations/opencode/json-reader.ts +304 -0
  41. package/src/integrations/opencode/reader-factory.ts +35 -0
  42. package/src/integrations/opencode/sqlite-reader.ts +189 -0
  43. package/src/integrations/opencode/types.ts +244 -0
  44. package/src/prompts/AGENTS.md +62 -0
  45. package/src/prompts/ceremony/description-check.ts +47 -0
  46. package/src/prompts/ceremony/expire.ts +30 -0
  47. package/src/prompts/ceremony/explore.ts +60 -0
  48. package/src/prompts/ceremony/index.ts +11 -0
  49. package/src/prompts/ceremony/types.ts +42 -0
  50. package/src/prompts/generation/descriptions.ts +91 -0
  51. package/src/prompts/generation/index.ts +15 -0
  52. package/src/prompts/generation/persona.ts +155 -0
  53. package/src/prompts/generation/seeds.ts +31 -0
  54. package/src/prompts/generation/types.ts +47 -0
  55. package/src/prompts/heartbeat/check.ts +179 -0
  56. package/src/prompts/heartbeat/ei.ts +208 -0
  57. package/src/prompts/heartbeat/index.ts +15 -0
  58. package/src/prompts/heartbeat/types.ts +70 -0
  59. package/src/prompts/human/fact-scan.ts +152 -0
  60. package/src/prompts/human/index.ts +32 -0
  61. package/src/prompts/human/item-match.ts +74 -0
  62. package/src/prompts/human/item-update.ts +322 -0
  63. package/src/prompts/human/person-scan.ts +115 -0
  64. package/src/prompts/human/topic-scan.ts +135 -0
  65. package/src/prompts/human/trait-scan.ts +115 -0
  66. package/src/prompts/human/types.ts +127 -0
  67. package/src/prompts/index.ts +90 -0
  68. package/src/prompts/message-utils.ts +39 -0
  69. package/src/prompts/persona/index.ts +16 -0
  70. package/src/prompts/persona/topics-match.ts +69 -0
  71. package/src/prompts/persona/topics-scan.ts +98 -0
  72. package/src/prompts/persona/topics-update.ts +157 -0
  73. package/src/prompts/persona/traits.ts +117 -0
  74. package/src/prompts/persona/types.ts +74 -0
  75. package/src/prompts/response/index.ts +147 -0
  76. package/src/prompts/response/sections.ts +355 -0
  77. package/src/prompts/response/types.ts +38 -0
  78. package/src/prompts/validation/ei.ts +93 -0
  79. package/src/prompts/validation/index.ts +6 -0
  80. package/src/prompts/validation/types.ts +22 -0
  81. package/src/storage/crypto.ts +96 -0
  82. package/src/storage/index.ts +5 -0
  83. package/src/storage/interface.ts +9 -0
  84. package/src/storage/local.ts +79 -0
  85. package/src/storage/merge.ts +69 -0
  86. package/src/storage/remote.ts +145 -0
  87. package/src/templates/welcome.ts +91 -0
  88. package/tui/README.md +62 -0
  89. package/tui/bunfig.toml +4 -0
  90. package/tui/src/app.tsx +55 -0
  91. package/tui/src/commands/archive.tsx +93 -0
  92. package/tui/src/commands/context.tsx +124 -0
  93. package/tui/src/commands/delete.tsx +71 -0
  94. package/tui/src/commands/details.tsx +41 -0
  95. package/tui/src/commands/editor.tsx +46 -0
  96. package/tui/src/commands/help.tsx +12 -0
  97. package/tui/src/commands/me.tsx +145 -0
  98. package/tui/src/commands/model.ts +47 -0
  99. package/tui/src/commands/new.ts +31 -0
  100. package/tui/src/commands/pause.ts +46 -0
  101. package/tui/src/commands/persona.tsx +58 -0
  102. package/tui/src/commands/provider.tsx +124 -0
  103. package/tui/src/commands/quit.ts +22 -0
  104. package/tui/src/commands/quotes.tsx +172 -0
  105. package/tui/src/commands/registry.test.ts +137 -0
  106. package/tui/src/commands/registry.ts +130 -0
  107. package/tui/src/commands/resume.ts +39 -0
  108. package/tui/src/commands/setsync.tsx +43 -0
  109. package/tui/src/commands/settings.tsx +83 -0
  110. package/tui/src/components/ConfirmOverlay.tsx +51 -0
  111. package/tui/src/components/ConflictOverlay.tsx +78 -0
  112. package/tui/src/components/HelpOverlay.tsx +69 -0
  113. package/tui/src/components/Layout.tsx +24 -0
  114. package/tui/src/components/MessageList.tsx +174 -0
  115. package/tui/src/components/PersonaListOverlay.tsx +186 -0
  116. package/tui/src/components/PromptInput.tsx +145 -0
  117. package/tui/src/components/ProviderListOverlay.tsx +208 -0
  118. package/tui/src/components/QuotesOverlay.tsx +157 -0
  119. package/tui/src/components/Sidebar.tsx +95 -0
  120. package/tui/src/components/StatusBar.tsx +77 -0
  121. package/tui/src/components/WelcomeOverlay.tsx +73 -0
  122. package/tui/src/context/ei.tsx +623 -0
  123. package/tui/src/context/keyboard.tsx +164 -0
  124. package/tui/src/context/overlay.tsx +53 -0
  125. package/tui/src/index.tsx +8 -0
  126. package/tui/src/storage/file.ts +185 -0
  127. package/tui/src/util/duration.ts +32 -0
  128. package/tui/src/util/editor.ts +188 -0
  129. package/tui/src/util/logger.ts +109 -0
  130. package/tui/src/util/persona-editor.tsx +181 -0
  131. package/tui/src/util/provider-editor.tsx +168 -0
  132. package/tui/src/util/syntax.ts +35 -0
  133. 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
+ }