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,145 @@
1
+ import type { TextareaRenderable, KeyBinding } from "@opentui/core";
2
+ import { useEi } from "../context/ei";
3
+ import { useKeyboardNav } from "../context/keyboard";
4
+ import { parseAndExecute, registerCommand, type CommandContext } from "../commands/registry";
5
+ import { quitCommand } from "../commands/quit";
6
+ import { helpCommand } from "../commands/help";
7
+ import { personaCommand } from "../commands/persona";
8
+ import { archiveCommand, unarchiveCommand } from "../commands/archive";
9
+ import { newCommand } from "../commands/new";
10
+ import { pauseCommand } from "../commands/pause";
11
+ import { resumeCommand } from "../commands/resume";
12
+ import { modelCommand } from "../commands/model";
13
+ import { detailsCommand } from "../commands/details";
14
+ import { meCommand } from "../commands/me";
15
+ import { editorCommand } from "../commands/editor";
16
+ import { settingsCommand } from "../commands/settings";
17
+ import { contextCommand } from "../commands/context.js";
18
+ import { deleteCommand } from "../commands/delete";
19
+ import { quotesCommand } from "../commands/quotes";
20
+ import { providerCommand } from "../commands/provider";
21
+ import { setSyncCommand } from "../commands/setsync";
22
+ import { useOverlay } from "../context/overlay";
23
+
24
+ const TEXTAREA_KEYBINDINGS: KeyBinding[] = [
25
+ { name: "return", action: "submit" },
26
+ { name: "return", meta: true, action: "newline" },
27
+ { name: "j", ctrl: true, action: "newline" },
28
+ ];
29
+
30
+ export function PromptInput() {
31
+ const ei = useEi();
32
+ const { sendMessage, activePersonaId, stopProcessor, showNotification } = ei;
33
+ const { registerTextarea, registerEditorHandler, exitApp, renderer } = useKeyboardNav();
34
+ const { showOverlay, hideOverlay, overlayRenderer } = useOverlay();
35
+
36
+ registerCommand(helpCommand);
37
+ registerCommand(quitCommand);
38
+ registerCommand(meCommand);
39
+ registerCommand(quotesCommand);
40
+ registerCommand(editorCommand);
41
+ registerCommand(personaCommand);
42
+ registerCommand(detailsCommand);
43
+ registerCommand(archiveCommand);
44
+ registerCommand(unarchiveCommand);
45
+ registerCommand(newCommand);
46
+ registerCommand(pauseCommand);
47
+ registerCommand(resumeCommand);
48
+ registerCommand(modelCommand);
49
+ registerCommand(settingsCommand);
50
+ registerCommand(providerCommand);
51
+ registerCommand(setSyncCommand);
52
+ registerCommand(contextCommand);
53
+ registerCommand(deleteCommand);
54
+
55
+ let textareaRef: TextareaRenderable | undefined;
56
+
57
+ const getCommandContext = (): CommandContext => ({
58
+ showOverlay,
59
+ hideOverlay,
60
+ showNotification,
61
+ exitApp,
62
+ stopProcessor,
63
+ ei,
64
+ renderer,
65
+ setInputText: (text: string) => textareaRef?.setText(text),
66
+ getInputText: () => textareaRef?.plainText || "",
67
+ });
68
+
69
+ const handleSubmit = async () => {
70
+ const text = textareaRef?.plainText?.trim();
71
+ if (!text) return;
72
+
73
+ if (text.startsWith("/")) {
74
+ const isEditorCmd = text.startsWith("/editor") ||
75
+ text.startsWith("/edit") ||
76
+ text.startsWith("/e ") ||
77
+ text === "/e";
78
+ const opensEditorForData = text.startsWith("/me") ||
79
+ text.startsWith("/details") ||
80
+ text.startsWith("/d ") ||
81
+ text === "/d" ||
82
+ text.startsWith("/settings") ||
83
+ text.startsWith("/set ") ||
84
+ text === "/set" ||
85
+ text.startsWith("/p") ||
86
+ text.startsWith("/quotes") ||
87
+ text.startsWith("/q ") ||
88
+ text.startsWith("/context") ||
89
+ text.startsWith("/messages");
90
+
91
+ if (!isEditorCmd && !opensEditorForData) {
92
+ textareaRef?.clear();
93
+ }
94
+ await parseAndExecute(text, getCommandContext());
95
+ if (opensEditorForData) {
96
+ textareaRef?.clear();
97
+ }
98
+ return;
99
+ }
100
+
101
+ textareaRef?.clear();
102
+ if (!activePersonaId()) return;
103
+ await sendMessage(text);
104
+ };
105
+
106
+ const handleEditor = async () => {
107
+ await editorCommand.execute([], getCommandContext());
108
+ };
109
+
110
+ registerEditorHandler(handleEditor);
111
+
112
+ const getPlaceholder = () => {
113
+ if (!activePersonaId()) return "Select a persona...";
114
+ return "Type your message... (Enter to send, Ctrl+E for editor)";
115
+ };
116
+
117
+ return (
118
+ <box
119
+ flexShrink={0}
120
+ border={["top"]}
121
+ borderStyle="single"
122
+ backgroundColor="#0f3460"
123
+ paddingLeft={1}
124
+ paddingRight={1}
125
+ paddingTop={0.5}
126
+ paddingBottom={0.5}
127
+ >
128
+ <textarea
129
+ ref={(r: TextareaRenderable) => {
130
+ textareaRef = r;
131
+ registerTextarea(r);
132
+ }}
133
+ focused={!overlayRenderer()}
134
+ onSubmit={() => void handleSubmit()}
135
+ placeholder={getPlaceholder()}
136
+ textColor="#eee8d5"
137
+ backgroundColor="#0f3460"
138
+ cursorColor="#eee8d5"
139
+ minHeight={1}
140
+ maxHeight={6}
141
+ keyBindings={overlayRenderer() ? [] : TEXTAREA_KEYBINDINGS}
142
+ />
143
+ </box>
144
+ );
145
+ }
@@ -0,0 +1,208 @@
1
+ import { useKeyboard } from "@opentui/solid";
2
+ import { For, createSignal, createMemo } from "solid-js";
3
+ import type { KeyEvent } from "@opentui/core";
4
+
5
+ export interface ProviderListItem {
6
+ id: string;
7
+ displayName: string;
8
+ key: string;
9
+ defaultModel?: string;
10
+ enabled: boolean;
11
+ }
12
+
13
+ interface ProviderListOverlayProps {
14
+ providers: ProviderListItem[];
15
+ activeProviderKey: string | null;
16
+ onSelect: (provider: ProviderListItem) => void;
17
+ onEdit: (provider: ProviderListItem) => void;
18
+ onNew: () => void;
19
+ onDismiss: () => void;
20
+ }
21
+
22
+ export function ProviderListOverlay(props: ProviderListOverlayProps) {
23
+ const [selectedIndex, setSelectedIndex] = createSignal(0);
24
+ const [filterText, setFilterText] = createSignal("");
25
+ const [filterMode, setFilterMode] = createSignal(false);
26
+
27
+ const filteredProviders = createMemo(() => {
28
+ const filter = filterText().toLowerCase();
29
+ if (!filter) return props.providers;
30
+ return props.providers.filter((p) =>
31
+ p.displayName.toLowerCase().includes(filter) ||
32
+ p.key.toLowerCase().includes(filter)
33
+ );
34
+ });
35
+
36
+ createMemo(() => {
37
+ const list = filteredProviders();
38
+ if (selectedIndex() >= list.length) {
39
+ setSelectedIndex(Math.max(0, list.length - 1));
40
+ }
41
+ });
42
+
43
+ useKeyboard((event: KeyEvent) => {
44
+ const key = event.name;
45
+ const listLength = filteredProviders().length;
46
+
47
+ if (filterMode()) {
48
+ if (key === "escape") {
49
+ event.preventDefault();
50
+ setFilterText("");
51
+ setFilterMode(false);
52
+ return;
53
+ }
54
+
55
+ if (key === "backspace") {
56
+ event.preventDefault();
57
+ setFilterText((prev) => prev.slice(0, -1));
58
+ return;
59
+ }
60
+
61
+ if (key === "return") {
62
+ event.preventDefault();
63
+ if (listLength > 0) {
64
+ props.onSelect(filteredProviders()[selectedIndex()]);
65
+ }
66
+ return;
67
+ }
68
+
69
+ if (key.length === 1 && !event.ctrl && !event.meta) {
70
+ event.preventDefault();
71
+ setFilterText((prev) => prev + key);
72
+ return;
73
+ }
74
+ } else {
75
+ if (key === "j" || key === "down") {
76
+ event.preventDefault();
77
+ setSelectedIndex((prev) => Math.min(prev + 1, listLength - 1));
78
+ return;
79
+ }
80
+
81
+ if (key === "k" || key === "up") {
82
+ event.preventDefault();
83
+ setSelectedIndex((prev) => Math.max(prev - 1, 0));
84
+ return;
85
+ }
86
+
87
+ if (key === "return") {
88
+ event.preventDefault();
89
+ if (listLength > 0) {
90
+ props.onSelect(filteredProviders()[selectedIndex()]);
91
+ }
92
+ return;
93
+ }
94
+
95
+ if (key === "e") {
96
+ event.preventDefault();
97
+ if (listLength > 0) {
98
+ props.onEdit(filteredProviders()[selectedIndex()]);
99
+ }
100
+ return;
101
+ }
102
+
103
+ if (key === "n") {
104
+ event.preventDefault();
105
+ props.onNew();
106
+ return;
107
+ }
108
+
109
+ if (key === "escape") {
110
+ event.preventDefault();
111
+ props.onDismiss();
112
+ return;
113
+ }
114
+
115
+ if (key === "/") {
116
+ event.preventDefault();
117
+ setFilterMode(true);
118
+ return;
119
+ }
120
+ }
121
+ });
122
+
123
+ const truncateModel = (model: string | undefined, maxLen: number = 30): string => {
124
+ if (!model) return "";
125
+ return model.length > maxLen ? model.slice(0, maxLen - 3) + "..." : model;
126
+ };
127
+
128
+ return (
129
+ <box
130
+ position="absolute"
131
+ width="100%"
132
+ height="100%"
133
+ left={0}
134
+ top={0}
135
+ backgroundColor="#000000"
136
+ alignItems="center"
137
+ justifyContent="center"
138
+ >
139
+ <box
140
+ width={70}
141
+ height="80%"
142
+ backgroundColor="#1a1a2e"
143
+ borderStyle="single"
144
+ borderColor="#586e75"
145
+ padding={2}
146
+ flexDirection="column"
147
+ >
148
+ <text fg="#eee8d5" marginBottom={1}>
149
+ Select Provider
150
+ </text>
151
+
152
+ <scrollbox height="100%">
153
+ <For each={filteredProviders()}>
154
+ {(provider, index) => {
155
+ const isActive = () => props.activeProviderKey === provider.key;
156
+ const isSelected = () => selectedIndex() === index();
157
+ const modelText = truncateModel(provider.defaultModel);
158
+ const label = () => {
159
+ const prefix = isActive() ? "> " : " ";
160
+ const model = modelText ? ` - ${modelText}` : "";
161
+ const disabled = !provider.enabled ? " (disabled)" : "";
162
+ return `${prefix}${provider.displayName}${model}${disabled}`;
163
+ };
164
+
165
+ return (
166
+ <box
167
+ backgroundColor={
168
+ isSelected()
169
+ ? "#2d3748"
170
+ : isActive()
171
+ ? "#1f2937"
172
+ : "transparent"
173
+ }
174
+ paddingLeft={1}
175
+ paddingRight={1}
176
+ >
177
+ <text
178
+ fg={
179
+ !provider.enabled
180
+ ? "#4a5568"
181
+ : isSelected()
182
+ ? "#eee8d5"
183
+ : isActive()
184
+ ? "#93a1a1"
185
+ : "#839496"
186
+ }
187
+ >
188
+ {label()}
189
+ </text>
190
+ </box>
191
+ );
192
+ }}
193
+ </For>
194
+ </scrollbox>
195
+
196
+ <text> </text>
197
+
198
+ {filterMode() ? (
199
+ <text fg="#586e75">Filter: {filterText()}|</text>
200
+ ) : (
201
+ <text fg="#586e75">
202
+ j/k: navigate | Enter: select | e: edit | n: new | Esc: cancel | /: filter
203
+ </text>
204
+ )}
205
+ </box>
206
+ </box>
207
+ );
208
+ }
@@ -0,0 +1,157 @@
1
+ import { useKeyboard } from "@opentui/solid";
2
+ import { For, createSignal, createMemo } from "solid-js";
3
+ import type { KeyEvent } from "@opentui/core";
4
+ import type { Quote } from "../../../src/core/types.js";
5
+
6
+ interface QuotesOverlayProps {
7
+ quotes: Quote[];
8
+ messageIndex: number;
9
+ onClose: () => void;
10
+ onEdit: () => Promise<void>;
11
+ onDelete: (quoteId: string) => Promise<void>;
12
+ }
13
+
14
+ export function QuotesOverlay(props: QuotesOverlayProps) {
15
+ const [selectedIndex, setSelectedIndex] = createSignal(0);
16
+ const [confirmDelete, setConfirmDelete] = createSignal(false);
17
+
18
+ createMemo(() => {
19
+ if (selectedIndex() >= props.quotes.length) {
20
+ setSelectedIndex(Math.max(0, props.quotes.length - 1));
21
+ }
22
+ });
23
+
24
+ useKeyboard((event: KeyEvent) => {
25
+ const key = event.name;
26
+ const listLength = props.quotes.length;
27
+
28
+ if (confirmDelete()) {
29
+ event.preventDefault();
30
+ if (key === "y") {
31
+ const quote = props.quotes[selectedIndex()];
32
+ if (quote) {
33
+ void props.onDelete(quote.id);
34
+ }
35
+ setConfirmDelete(false);
36
+ if (props.quotes.length <= 1) {
37
+ props.onClose();
38
+ }
39
+ } else if (key === "n" || key === "escape") {
40
+ setConfirmDelete(false);
41
+ }
42
+ return;
43
+ }
44
+
45
+ if (key === "j" || key === "down") {
46
+ event.preventDefault();
47
+ setSelectedIndex((prev) => Math.min(prev + 1, listLength - 1));
48
+ return;
49
+ }
50
+
51
+ if (key === "k" || key === "up") {
52
+ event.preventDefault();
53
+ setSelectedIndex((prev) => Math.max(prev - 1, 0));
54
+ return;
55
+ }
56
+
57
+ if (key === "e") {
58
+ event.preventDefault();
59
+ void props.onEdit();
60
+ return;
61
+ }
62
+
63
+ if (key === "d") {
64
+ event.preventDefault();
65
+ if (listLength > 0) {
66
+ setConfirmDelete(true);
67
+ }
68
+ return;
69
+ }
70
+
71
+ if (key === "escape") {
72
+ event.preventDefault();
73
+ props.onClose();
74
+ return;
75
+ }
76
+ });
77
+
78
+ const truncateText = (text: string, maxLen: number = 60): string => {
79
+ const singleLine = text.replace(/\n/g, " ").trim();
80
+ return singleLine.length > maxLen ? singleLine.slice(0, maxLen - 3) + "..." : singleLine;
81
+ };
82
+
83
+ const formatTimestamp = (timestamp: string): string => {
84
+ const date = new Date(timestamp);
85
+ return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
86
+ };
87
+
88
+ return (
89
+ <box
90
+ position="absolute"
91
+ width="100%"
92
+ height="100%"
93
+ left={0}
94
+ top={0}
95
+ backgroundColor="#000000"
96
+ alignItems="center"
97
+ justifyContent="center"
98
+ >
99
+ <box
100
+ width={70}
101
+ height="70%"
102
+ backgroundColor="#1a1a2e"
103
+ borderStyle="single"
104
+ borderColor="#586e75"
105
+ padding={2}
106
+ flexDirection="column"
107
+ >
108
+ <text fg="#eee8d5" marginBottom={1}>
109
+ Quotes from message [{props.messageIndex}]
110
+ </text>
111
+
112
+ <box visible={props.quotes.length === 0}>
113
+ <text fg="#586e75">No quotes in this message</text>
114
+ </box>
115
+
116
+ <scrollbox height="100%" visible={props.quotes.length > 0}>
117
+ <For each={props.quotes}>
118
+ {(quote, index) => {
119
+ const isSelected = () => selectedIndex() === index();
120
+ const displayText = truncateText(quote.text);
121
+ const speaker = quote.speaker === "human" ? "You" : quote.speaker;
122
+
123
+ return (
124
+ <box
125
+ backgroundColor={isSelected() ? "#2d3748" : "transparent"}
126
+ paddingLeft={1}
127
+ paddingRight={1}
128
+ marginBottom={1}
129
+ flexDirection="column"
130
+ >
131
+ <text fg={isSelected() ? "#b58900" : "#839496"}>
132
+ {isSelected() ? "▶ " : " "}{speaker} ({formatTimestamp(quote.timestamp)})
133
+ </text>
134
+ <text fg={isSelected() ? "#eee8d5" : "#93a1a1"} marginLeft={2}>
135
+ "{displayText}"
136
+ </text>
137
+ </box>
138
+ );
139
+ }}
140
+ </For>
141
+ </scrollbox>
142
+
143
+ <text> </text>
144
+
145
+ <box visible={confirmDelete()}>
146
+ <text fg="#dc322f">Delete this quote? (y/N)</text>
147
+ </box>
148
+
149
+ <box visible={!confirmDelete()}>
150
+ <text fg="#586e75">
151
+ j/k: navigate | e: edit in $EDITOR | d: delete | Esc: close
152
+ </text>
153
+ </box>
154
+ </box>
155
+ </box>
156
+ );
157
+ }
@@ -0,0 +1,95 @@
1
+ import { For, createSignal, createEffect, createMemo, onCleanup } from "solid-js";
2
+ import { useEi } from "../context/ei";
3
+ import { useKeyboardNav } from "../context/keyboard";
4
+
5
+ export function Sidebar() {
6
+ const { personas, activePersonaId } = useEi();
7
+ const { focusedPanel } = useKeyboardNav();
8
+
9
+ const isFocused = () => focusedPanel() === "sidebar";
10
+
11
+ // Memoize visible (non-archived) personas for proper reactivity
12
+ const visiblePersonas = createMemo(() =>
13
+ personas().filter(p => !p.is_archived)
14
+ );
15
+
16
+ const [highlightedPersona, setHighlightedPersona] = createSignal<string | null>(null);
17
+ let highlightTimer: ReturnType<typeof setTimeout> | null = null;
18
+
19
+ createEffect(() => {
20
+ const currentId = activePersonaId();
21
+ if (currentId) {
22
+ if (highlightTimer) clearTimeout(highlightTimer);
23
+
24
+ setHighlightedPersona(currentId);
25
+
26
+ highlightTimer = setTimeout(() => {
27
+ setHighlightedPersona(null);
28
+ highlightTimer = null;
29
+ }, 500);
30
+ }
31
+ });
32
+
33
+ onCleanup(() => {
34
+ if (highlightTimer) clearTimeout(highlightTimer);
35
+ });
36
+
37
+ return (
38
+ <box
39
+ width={25}
40
+ border={["right"]}
41
+ borderStyle="single"
42
+ borderColor={isFocused() ? "#268bd2" : "#586e75"}
43
+ padding={1}
44
+ backgroundColor="#1a1a2e"
45
+ >
46
+ <box flexDirection="column">
47
+ <text fg={isFocused() ? "#268bd2" : "#93a1a1"} marginBottom={1}>
48
+ {`Personas ${isFocused() ? "[*]" : ""}`}
49
+ </text>
50
+
51
+ <scrollbox height="100%">
52
+ <For each={visiblePersonas()}>
53
+ {(persona) => {
54
+ const isActive = () => activePersonaId() === persona.id;
55
+ const displayName = () =>
56
+ persona.display_name || persona.aliases[0] || persona.id;
57
+
58
+ const getLabel = () => {
59
+ const prefix = isActive() ? "* " : " ";
60
+ const name = displayName();
61
+ const unread = persona.unread_count > 0 ? ` (${persona.unread_count} new)` : "";
62
+ const paused = persona.is_paused ? " ⏸" : "";
63
+ return `${prefix}${name}${unread}${paused}`;
64
+ };
65
+
66
+ const textColor = () => {
67
+ if (isActive()) return "#eee8d5";
68
+ if (persona.is_paused) return "#586e75";
69
+ return "#839496";
70
+ };
71
+
72
+ return (
73
+ <box
74
+ backgroundColor={
75
+ isActive() && highlightedPersona() === persona.id
76
+ ? "#3d5a80"
77
+ : isActive()
78
+ ? "#2d3748"
79
+ : "transparent"
80
+ }
81
+ padding={1}
82
+ marginBottom={0.5}
83
+ >
84
+ <text fg={textColor()}>
85
+ {getLabel()}
86
+ </text>
87
+ </box>
88
+ );
89
+ }}
90
+ </For>
91
+ </scrollbox>
92
+ </box>
93
+ </box>
94
+ );
95
+ }
@@ -0,0 +1,77 @@
1
+ import { Show } from "solid-js";
2
+ import { useEi } from "../context/ei";
3
+ import { useKeyboardNav } from "../context/keyboard";
4
+
5
+ export function StatusBar() {
6
+ const { activePersonaId, personas, queueStatus, notification } = useEi();
7
+ const { focusedPanel, sidebarVisible } = useKeyboardNav();
8
+
9
+ const getActiveDisplayName = () => {
10
+ const id = activePersonaId();
11
+ if (!id) return null;
12
+ const persona = personas().find(p => p.id === id);
13
+ return persona?.display_name ?? id;
14
+ };
15
+
16
+ const getQueueIndicator = () => {
17
+ const status = queueStatus();
18
+ if (status.state === "busy") {
19
+ return `Processing (${status.pending_count})`;
20
+ }
21
+ if (status.state === "paused") {
22
+ return "Paused";
23
+ }
24
+ return "Ready";
25
+ };
26
+
27
+ const getFocusIndicator = () => {
28
+ const panel = focusedPanel();
29
+ return panel.charAt(0).toUpperCase() + panel.slice(1);
30
+ };
31
+
32
+ const getNotificationColor = () => {
33
+ const n = notification();
34
+ if (!n) return "#586e75";
35
+ if (n.level === "error") return "#dc322f";
36
+ if (n.level === "warn") return "#b58900";
37
+ return "#2aa198";
38
+ };
39
+
40
+ return (
41
+ <box
42
+ height={1}
43
+ backgroundColor="#16213e"
44
+ paddingLeft={1}
45
+ paddingRight={1}
46
+ flexDirection="row"
47
+ >
48
+ <box flexGrow={1}>
49
+ <Show when={notification()} fallback={
50
+ <text fg="#586e75">
51
+ <Show when={getActiveDisplayName()} fallback="No persona selected">
52
+ {getActiveDisplayName()}
53
+ </Show>
54
+ </text>
55
+ }>
56
+ <text fg={getNotificationColor()}>
57
+ {notification()?.message}
58
+ </text>
59
+ </Show>
60
+ </box>
61
+
62
+ <text fg="#586e75" marginRight={2}>
63
+ [{getFocusIndicator()}]
64
+ </text>
65
+
66
+ <Show when={!sidebarVisible()}>
67
+ <text fg="#586e75" marginRight={2}>
68
+ [S]
69
+ </text>
70
+ </Show>
71
+
72
+ <text fg={queueStatus().state === "busy" ? "#b58900" : "#586e75"}>
73
+ {getQueueIndicator()}
74
+ </text>
75
+ </box>
76
+ );
77
+ }