ei-tui 0.4.3 → 0.5.1

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 (102) hide show
  1. package/README.md +14 -0
  2. package/package.json +1 -1
  3. package/src/cli/README.md +17 -12
  4. package/src/cli/commands/personas.ts +12 -0
  5. package/src/cli/mcp.ts +2 -2
  6. package/src/cli/retrieval.ts +86 -8
  7. package/src/cli.ts +8 -5
  8. package/src/core/constants/seed-traits.ts +29 -0
  9. package/src/core/context-utils.ts +1 -0
  10. package/src/core/handlers/human-matching.ts +86 -56
  11. package/src/core/handlers/index.ts +5 -0
  12. package/src/core/handlers/persona-preview.ts +7 -0
  13. package/src/core/handlers/persona-topics.ts +3 -2
  14. package/src/core/handlers/rooms.ts +176 -0
  15. package/src/core/handlers/utils.ts +55 -3
  16. package/src/core/heartbeat-manager.ts +3 -1
  17. package/src/core/llm-client.ts +1 -1
  18. package/src/core/message-manager.ts +10 -8
  19. package/src/core/orchestrators/human-extraction.ts +15 -2
  20. package/src/core/orchestrators/index.ts +1 -0
  21. package/src/core/orchestrators/persona-generation.ts +4 -0
  22. package/src/core/orchestrators/persona-topics.ts +2 -1
  23. package/src/core/orchestrators/room-extraction.ts +318 -0
  24. package/src/core/persona-manager.ts +16 -5
  25. package/src/core/personas/opencode-agent.ts +12 -2
  26. package/src/core/processor.ts +520 -4
  27. package/src/core/prompt-context-builder.ts +89 -5
  28. package/src/core/queue-processor.ts +68 -8
  29. package/src/core/room-manager.ts +408 -0
  30. package/src/core/state/index.ts +1 -0
  31. package/src/core/state/personas.ts +12 -2
  32. package/src/core/state/queue.ts +2 -2
  33. package/src/core/state/rooms.ts +182 -0
  34. package/src/core/state-manager.ts +124 -2
  35. package/src/core/tool-manager.ts +1 -1
  36. package/src/core/tools/index.ts +15 -0
  37. package/src/core/types/data-items.ts +3 -1
  38. package/src/core/types/enums.ts +11 -0
  39. package/src/core/types/integrations.ts +10 -2
  40. package/src/core/types/llm.ts +3 -0
  41. package/src/core/types/rooms.ts +59 -0
  42. package/src/core/types.ts +1 -0
  43. package/src/core/utils/decay.ts +14 -8
  44. package/src/core/utils/exposure.ts +14 -0
  45. package/src/integrations/claude-code/importer.ts +23 -10
  46. package/src/integrations/cursor/importer.ts +22 -10
  47. package/src/integrations/opencode/importer.ts +30 -13
  48. package/src/prompts/ceremony/dedup.ts +2 -2
  49. package/src/prompts/generation/from-person.ts +85 -0
  50. package/src/prompts/generation/index.ts +2 -0
  51. package/src/prompts/generation/persona.ts +14 -10
  52. package/src/prompts/generation/seeds.ts +4 -29
  53. package/src/prompts/generation/types.ts +13 -0
  54. package/src/prompts/heartbeat/check.ts +1 -1
  55. package/src/prompts/heartbeat/ei.ts +4 -4
  56. package/src/prompts/heartbeat/types.ts +1 -0
  57. package/src/prompts/index.ts +15 -0
  58. package/src/prompts/message-utils.ts +2 -2
  59. package/src/prompts/persona/topics-match.ts +7 -6
  60. package/src/prompts/persona/topics-update.ts +8 -11
  61. package/src/prompts/persona/types.ts +2 -1
  62. package/src/prompts/response/index.ts +1 -1
  63. package/src/prompts/response/sections.ts +20 -8
  64. package/src/prompts/response/types.ts +6 -0
  65. package/src/prompts/room/index.ts +115 -0
  66. package/src/prompts/room/sections.ts +150 -0
  67. package/src/prompts/room/types.ts +93 -0
  68. package/tui/README.md +20 -0
  69. package/tui/src/app.tsx +3 -2
  70. package/tui/src/commands/activate.tsx +98 -0
  71. package/tui/src/commands/archive.tsx +54 -25
  72. package/tui/src/commands/capture.tsx +50 -0
  73. package/tui/src/commands/dedupe.tsx +2 -7
  74. package/tui/src/commands/delete.tsx +48 -0
  75. package/tui/src/commands/details.tsx +7 -0
  76. package/tui/src/commands/persona.tsx +271 -9
  77. package/tui/src/commands/room.tsx +261 -0
  78. package/tui/src/commands/silence.tsx +29 -0
  79. package/tui/src/components/ArchivedItemsOverlay.tsx +144 -0
  80. package/tui/src/components/ConfirmOverlay.tsx +6 -0
  81. package/tui/src/components/ConflictOverlay.tsx +6 -0
  82. package/tui/src/components/HelpOverlay.tsx +6 -1
  83. package/tui/src/components/LoadingOverlay.tsx +51 -0
  84. package/tui/src/components/MessageList.tsx +1 -18
  85. package/tui/src/components/PersonPickerOverlay.tsx +121 -0
  86. package/tui/src/components/PersonaListOverlay.tsx +6 -1
  87. package/tui/src/components/PromptInput.tsx +141 -8
  88. package/tui/src/components/ProviderListOverlay.tsx +5 -1
  89. package/tui/src/components/QuotesOverlay.tsx +5 -1
  90. package/tui/src/components/RoomMessageList.tsx +179 -0
  91. package/tui/src/components/Sidebar.tsx +54 -2
  92. package/tui/src/components/StatusBar.tsx +99 -8
  93. package/tui/src/components/ToolkitListOverlay.tsx +5 -1
  94. package/tui/src/components/WelcomeOverlay.tsx +6 -0
  95. package/tui/src/context/ei.tsx +252 -1
  96. package/tui/src/context/keyboard.tsx +48 -12
  97. package/tui/src/util/cyp-editor.tsx +152 -0
  98. package/tui/src/util/quote-utils.ts +19 -0
  99. package/tui/src/util/room-editor.tsx +164 -0
  100. package/tui/src/util/room-logic.ts +8 -0
  101. package/tui/src/util/room-parser.ts +70 -0
  102. package/tui/src/util/yaml-serializers.ts +151 -0
@@ -0,0 +1,261 @@
1
+ import { For, createSignal, createMemo, onMount, onCleanup } from "solid-js";
2
+ import { useKeyboard } from "@opentui/solid";
3
+ import type { KeyEvent } from "@opentui/core";
4
+ import type { Command } from "./registry";
5
+ import { spawnEditor } from "../util/editor.js";
6
+ import { RoomMode } from "../../../src/core/types/enums.js";
7
+ import type { RoomSummary } from "../../../src/core/types.js";
8
+ import { useKeyboardNav } from "../context/keyboard.js";
9
+ import { buildRoomYAMLTemplate, parseRoomYAML } from "../util/room-parser.js";
10
+
11
+ function modeBadge(mode: RoomMode): string {
12
+ switch (mode) {
13
+ case RoomMode.ChooseYourPath: return "[CYP]";
14
+ case RoomMode.FreeForAll: return "[FFA]";
15
+ case RoomMode.MessagesAgainstPersona: return "[MAP]";
16
+ default: return "[???]";
17
+ }
18
+ }
19
+
20
+ interface RoomListOverlayProps {
21
+ rooms: RoomSummary[];
22
+ activeRoomId: string | null;
23
+ onSelect: (roomId: string) => void;
24
+ onDismiss: () => void;
25
+ }
26
+
27
+ function RoomListOverlay(props: RoomListOverlayProps) {
28
+ const { setOverlayActive } = useKeyboardNav();
29
+ const [selectedIndex, setSelectedIndex] = createSignal(0);
30
+ const [filterText, setFilterText] = createSignal("");
31
+ const [filterMode, setFilterMode] = createSignal(false);
32
+
33
+ onMount(() => setOverlayActive(true));
34
+ onCleanup(() => setOverlayActive(false));
35
+
36
+ const filteredRooms = createMemo(() => {
37
+ const filter = filterText().toLowerCase();
38
+ if (!filter) return props.rooms;
39
+ return props.rooms.filter((r) =>
40
+ r.display_name.toLowerCase().includes(filter)
41
+ );
42
+ });
43
+
44
+ createMemo(() => {
45
+ const list = filteredRooms();
46
+ if (selectedIndex() >= list.length) {
47
+ setSelectedIndex(Math.max(0, list.length - 1));
48
+ }
49
+ });
50
+
51
+ useKeyboard((event: KeyEvent) => {
52
+ const key = event.name;
53
+ const listLength = filteredRooms().length;
54
+
55
+ if (filterMode()) {
56
+ if (key === "escape") {
57
+ event.preventDefault();
58
+ setFilterText("");
59
+ setFilterMode(false);
60
+ return;
61
+ }
62
+ if (key === "backspace") {
63
+ event.preventDefault();
64
+ setFilterText((prev) => prev.slice(0, -1));
65
+ return;
66
+ }
67
+ if (key === "return") {
68
+ event.preventDefault();
69
+ if (listLength > 0) {
70
+ const selected = filteredRooms()[selectedIndex()];
71
+ props.onSelect(selected.id);
72
+ }
73
+ return;
74
+ }
75
+ if (key.length === 1 && !event.ctrl && !event.meta) {
76
+ event.preventDefault();
77
+ setFilterText((prev) => prev + key);
78
+ return;
79
+ }
80
+ } else {
81
+ if (key === "j" || key === "down") {
82
+ event.preventDefault();
83
+ setSelectedIndex((prev) => Math.min(prev + 1, listLength - 1));
84
+ return;
85
+ }
86
+ if (key === "k" || key === "up") {
87
+ event.preventDefault();
88
+ setSelectedIndex((prev) => Math.max(prev - 1, 0));
89
+ return;
90
+ }
91
+ if (key === "return") {
92
+ event.preventDefault();
93
+ if (listLength > 0) {
94
+ const selected = filteredRooms()[selectedIndex()];
95
+ props.onSelect(selected.id);
96
+ }
97
+ return;
98
+ }
99
+ if (key === "escape") {
100
+ event.preventDefault();
101
+ props.onDismiss();
102
+ return;
103
+ }
104
+ if (key === "/") {
105
+ event.preventDefault();
106
+ setFilterMode(true);
107
+ return;
108
+ }
109
+ }
110
+ });
111
+
112
+ return (
113
+ <box
114
+ position="absolute"
115
+ width="100%"
116
+ height="100%"
117
+ left={0}
118
+ top={0}
119
+ backgroundColor="#000000"
120
+ alignItems="center"
121
+ justifyContent="center"
122
+ >
123
+ <box
124
+ width={70}
125
+ height="80%"
126
+ backgroundColor="#1a1a2e"
127
+ borderStyle="single"
128
+ borderColor="#586e75"
129
+ padding={2}
130
+ flexDirection="column"
131
+ >
132
+ <text fg="#eee8d5" marginBottom={1}>
133
+ Select Room
134
+ </text>
135
+
136
+ <scrollbox height="100%">
137
+ <For each={filteredRooms()}>
138
+ {(room, index) => {
139
+ const isActive = () => props.activeRoomId === room.id;
140
+ const isSelected = () => selectedIndex() === index();
141
+ const label = () => {
142
+ const prefix = isActive() ? "> " : " ";
143
+ const badge = modeBadge(room.mode);
144
+ const unread = room.unread_count > 0 ? ` (${room.unread_count})` : "";
145
+ return `${prefix}${badge} ${room.display_name}${unread}`;
146
+ };
147
+
148
+ return (
149
+ <box
150
+ backgroundColor={
151
+ isSelected()
152
+ ? "#2d3748"
153
+ : isActive()
154
+ ? "#1f2937"
155
+ : "transparent"
156
+ }
157
+ paddingLeft={1}
158
+ paddingRight={1}
159
+ >
160
+ <text
161
+ fg={
162
+ isSelected()
163
+ ? "#eee8d5"
164
+ : isActive()
165
+ ? "#93a1a1"
166
+ : "#839496"
167
+ }
168
+ >
169
+ {label()}
170
+ </text>
171
+ </box>
172
+ );
173
+ }}
174
+ </For>
175
+ </scrollbox>
176
+
177
+ <text> </text>
178
+
179
+ {filterMode() ? (
180
+ <text fg="#586e75">Filter: {filterText()}|</text>
181
+ ) : (
182
+ <text fg="#586e75">
183
+ j/k: navigate | Enter: select | Esc: cancel | /: filter
184
+ </text>
185
+ )}
186
+ </box>
187
+ </box>
188
+ );
189
+ }
190
+
191
+ export const roomCommand: Command = {
192
+ name: "room",
193
+ aliases: ["r"],
194
+ description: "List rooms, switch to a room, or create a new one",
195
+ usage: "/room | /room <name> | /room new",
196
+
197
+ async execute(args, ctx) {
198
+ const unarchived = ctx.ei.rooms().filter(r => !r.is_archived);
199
+
200
+ if (args.length === 0) {
201
+ if (unarchived.length === 0) {
202
+ ctx.showNotification("No rooms. Use /room new to create one.", "info");
203
+ return;
204
+ }
205
+ ctx.showOverlay((hideOverlay, _hideForEditor) => (
206
+ <RoomListOverlay
207
+ rooms={unarchived}
208
+ activeRoomId={ctx.ei.activeRoomId()}
209
+ onSelect={(roomId) => {
210
+ const room = unarchived.find(r => r.id === roomId);
211
+ ctx.ei.selectRoom(roomId);
212
+ hideOverlay();
213
+ ctx.showNotification(`Switched to ${room?.display_name ?? roomId}`, "info");
214
+ }}
215
+ onDismiss={hideOverlay}
216
+ />
217
+ ), ctx.renderer);
218
+ return;
219
+ }
220
+
221
+ if (args[0].toLowerCase() === "new") {
222
+ const personas = ctx.ei.personas();
223
+ const rawName = args.slice(1).join(" ").replace(/^["']|["']$/g, "");
224
+ const result = await spawnEditor({
225
+ initialContent: buildRoomYAMLTemplate(personas, rawName),
226
+ filename: "new-room.yaml",
227
+ renderer: ctx.renderer,
228
+ });
229
+
230
+ if (result.aborted) {
231
+ ctx.showNotification("Room creation cancelled", "info");
232
+ return;
233
+ }
234
+
235
+ if (!result.success || result.content === null) {
236
+ ctx.showNotification("No changes — room not created", "info");
237
+ return;
238
+ }
239
+
240
+ try {
241
+ const input = parseRoomYAML(result.content, personas);
242
+ await ctx.ei.createRoom(input);
243
+ ctx.showNotification(`Room "${input.display_name}" created`, "info");
244
+ } catch (err) {
245
+ const msg = err instanceof Error ? err.message : String(err);
246
+ ctx.showNotification(`Failed to create room: ${msg}`, "error");
247
+ }
248
+ return;
249
+ }
250
+
251
+ const name = args.join(" ");
252
+ const roomId = ctx.ei.resolveRoomName(name);
253
+ if (roomId) {
254
+ const room = unarchived.find(r => r.id === roomId);
255
+ ctx.ei.selectRoom(roomId);
256
+ ctx.showNotification(`Switched to ${room?.display_name ?? name}`, "info");
257
+ } else {
258
+ ctx.showNotification(`No room named "${name}". Use /room new to create one.`, "warn");
259
+ }
260
+ },
261
+ };
@@ -0,0 +1,29 @@
1
+ import type { Command } from "./registry";
2
+ import { RoomMode } from "../../../src/core/types/enums.js";
3
+
4
+ export const silenceCommand: Command = {
5
+ name: "silence",
6
+ aliases: [],
7
+ description: "Pass your turn with optional reason",
8
+ usage: "/silence [reason]",
9
+
10
+ async execute(args, ctx) {
11
+ const rawReason = args.join(" ").replace(/^["']|["']$/g, "").trim();
12
+ const reason = rawReason || "passed";
13
+
14
+ const roomId = ctx.ei.activeRoomId();
15
+ if (roomId) {
16
+ const room = ctx.ei.getRoom(roomId);
17
+ if (room?.mode === RoomMode.FreeForAll) {
18
+ await ctx.ei.sendFfaMessage(null, reason);
19
+ } else {
20
+ ctx.ei.submitHumanRoomMessage(null, reason);
21
+ }
22
+ ctx.showNotification(`Silence recorded: "${reason}"`, "info");
23
+ return;
24
+ }
25
+
26
+ await ctx.ei.sendSilenceMessage(reason);
27
+ ctx.showNotification("Silence recorded", "info");
28
+ },
29
+ };
@@ -0,0 +1,144 @@
1
+ import { useKeyboard } from "@opentui/solid";
2
+ import { For, createSignal, createMemo, onMount, onCleanup } from "solid-js";
3
+ import type { KeyEvent } from "@opentui/core";
4
+ import type { PersonaSummary, RoomSummary } from "../../../src/core/types.js";
5
+ import { useKeyboardNav } from "../context/keyboard.js";
6
+
7
+ export type ArchivedItem =
8
+ | { kind: "persona"; id: string; display_name: string }
9
+ | { kind: "room"; id: string; display_name: string; mode: RoomSummary["mode"] };
10
+
11
+ interface ArchivedItemsOverlayProps {
12
+ personas: PersonaSummary[];
13
+ rooms: RoomSummary[];
14
+ onSelect: (item: ArchivedItem) => void | Promise<void>;
15
+ onDismiss: () => void;
16
+ }
17
+
18
+ function modeLabel(mode: RoomSummary["mode"]): string {
19
+ switch (mode) {
20
+ case "choose_your_path": return "CYP";
21
+ case "free_for_all": return "FFA";
22
+ case "messages_against_persona": return "MAP";
23
+ default: return mode;
24
+ }
25
+ }
26
+
27
+ export function ArchivedItemsOverlay(props: ArchivedItemsOverlayProps) {
28
+ const { setOverlayActive } = useKeyboardNav();
29
+ const [selectedIndex, setSelectedIndex] = createSignal(0);
30
+
31
+ onMount(() => setOverlayActive(true));
32
+ onCleanup(() => setOverlayActive(false));
33
+
34
+ const allItems = createMemo<ArchivedItem[]>(() => [
35
+ ...props.rooms.map(r => ({
36
+ kind: "room" as const,
37
+ id: r.id,
38
+ display_name: r.display_name,
39
+ mode: r.mode,
40
+ })),
41
+ ...props.personas.map(p => ({
42
+ kind: "persona" as const,
43
+ id: p.id,
44
+ display_name: p.display_name,
45
+ })),
46
+ ]);
47
+
48
+ createMemo(() => {
49
+ const list = allItems();
50
+ if (selectedIndex() >= list.length) {
51
+ setSelectedIndex(Math.max(0, list.length - 1));
52
+ }
53
+ });
54
+
55
+ useKeyboard((event: KeyEvent) => {
56
+ const key = event.name;
57
+ const listLength = allItems().length;
58
+
59
+ if (key === "j" || key === "down") {
60
+ event.preventDefault();
61
+ setSelectedIndex((prev) => Math.min(prev + 1, listLength - 1));
62
+ return;
63
+ }
64
+
65
+ if (key === "k" || key === "up") {
66
+ event.preventDefault();
67
+ setSelectedIndex((prev) => Math.max(prev - 1, 0));
68
+ return;
69
+ }
70
+
71
+ if (key === "return") {
72
+ event.preventDefault();
73
+ if (listLength > 0) {
74
+ const selected = allItems()[selectedIndex()];
75
+ void props.onSelect(selected);
76
+ }
77
+ return;
78
+ }
79
+
80
+ if (key === "escape") {
81
+ event.preventDefault();
82
+ props.onDismiss();
83
+ return;
84
+ }
85
+ });
86
+
87
+ return (
88
+ <box
89
+ position="absolute"
90
+ width="100%"
91
+ height="100%"
92
+ left={0}
93
+ top={0}
94
+ backgroundColor="#000000"
95
+ alignItems="center"
96
+ justifyContent="center"
97
+ >
98
+ <box
99
+ width={70}
100
+ height="80%"
101
+ backgroundColor="#1a1a2e"
102
+ borderStyle="single"
103
+ borderColor="#586e75"
104
+ padding={2}
105
+ flexDirection="column"
106
+ >
107
+ <text fg="#eee8d5" marginBottom={1}>
108
+ Archived Items (Enter to unarchive)
109
+ </text>
110
+
111
+ <scrollbox height="100%">
112
+ <For each={allItems()}>
113
+ {(item, index) => {
114
+ const isSelected = () => selectedIndex() === index();
115
+ const label = () => {
116
+ if (item.kind === "room") {
117
+ return `[Room] ${item.display_name} (${modeLabel(item.mode)})`;
118
+ }
119
+ return `[Persona] ${item.display_name}`;
120
+ };
121
+ return (
122
+ <box
123
+ backgroundColor={isSelected() ? "#2d3748" : "transparent"}
124
+ paddingLeft={1}
125
+ paddingRight={1}
126
+ >
127
+ <text fg={isSelected() ? "#eee8d5" : "#839496"}>
128
+ {label()}
129
+ </text>
130
+ </box>
131
+ );
132
+ }}
133
+ </For>
134
+ </scrollbox>
135
+
136
+ <text> </text>
137
+
138
+ <text fg="#586e75">
139
+ j/k: navigate | Enter: unarchive | Esc: cancel
140
+ </text>
141
+ </box>
142
+ </box>
143
+ );
144
+ }
@@ -1,4 +1,6 @@
1
1
  import { useKeyboard } from "@opentui/solid";
2
+ import { onMount, onCleanup } from "solid-js";
3
+ import { useKeyboardNav } from "../context/keyboard.js";
2
4
 
3
5
  interface ConfirmOverlayProps {
4
6
  message: string;
@@ -7,6 +9,10 @@ interface ConfirmOverlayProps {
7
9
  }
8
10
 
9
11
  export function ConfirmOverlay(props: ConfirmOverlayProps) {
12
+ const { setOverlayActive } = useKeyboardNav();
13
+ onMount(() => setOverlayActive(true));
14
+ onCleanup(() => setOverlayActive(false));
15
+
10
16
  useKeyboard((event) => {
11
17
  event.preventDefault();
12
18
 
@@ -1,5 +1,7 @@
1
1
  import { useKeyboard } from "@opentui/solid";
2
+ import { onMount, onCleanup } from "solid-js";
2
3
  import type { StateConflictResolution } from "../../../src/core/types.js";
4
+ import { useKeyboardNav } from "../context/keyboard.js";
3
5
 
4
6
  interface ConflictOverlayProps {
5
7
  localTimestamp: Date;
@@ -17,6 +19,10 @@ function formatTimestamp(date: Date): string {
17
19
  }
18
20
 
19
21
  export function ConflictOverlay(props: ConflictOverlayProps) {
22
+ const { setOverlayActive } = useKeyboardNav();
23
+ onMount(() => setOverlayActive(true));
24
+ onCleanup(() => setOverlayActive(false));
25
+
20
26
  useKeyboard((event) => {
21
27
  event.preventDefault();
22
28
 
@@ -1,12 +1,17 @@
1
1
  import { useKeyboard } from "@opentui/solid";
2
- import { For } from "solid-js";
2
+ import { For, onMount, onCleanup } from "solid-js";
3
3
  import { getAllCommands } from "../commands/registry";
4
+ import { useKeyboardNav } from "../context/keyboard.js";
4
5
 
5
6
  interface HelpOverlayProps {
6
7
  onDismiss: () => void;
7
8
  }
8
9
 
9
10
  export function HelpOverlay(props: HelpOverlayProps) {
11
+ const { setOverlayActive } = useKeyboardNav();
12
+ onMount(() => setOverlayActive(true));
13
+ onCleanup(() => setOverlayActive(false));
14
+
10
15
  useKeyboard((event) => {
11
16
  event.preventDefault();
12
17
  props.onDismiss();
@@ -0,0 +1,51 @@
1
+ import { useKeyboard } from "@opentui/solid";
2
+ import { onMount, onCleanup } from "solid-js";
3
+ import { useKeyboardNav } from "../context/keyboard.js";
4
+
5
+ interface LoadingOverlayProps {
6
+ message: string;
7
+ onCancel?: () => void;
8
+ }
9
+
10
+ export function LoadingOverlay(props: LoadingOverlayProps) {
11
+ const { setOverlayActive } = useKeyboardNav();
12
+ onMount(() => setOverlayActive(true));
13
+ onCleanup(() => setOverlayActive(false));
14
+
15
+ useKeyboard((event) => {
16
+ if (event.name === "escape" && props.onCancel) {
17
+ event.preventDefault();
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
+ <box visible={!!props.onCancel}>
45
+ <text> </text>
46
+ <text fg="#586e75">Esc: cancel</text>
47
+ </box>
48
+ </box>
49
+ </box>
50
+ );
51
+ }
@@ -5,6 +5,7 @@ import { useKeyboardNav } from "../context/keyboard.js";
5
5
  import { logger } from "../util/logger.js";
6
6
  import { solarizedDarkSyntax } from "../util/syntax.js";
7
7
  import type { Quote, Message } from "../../../src/core/types.js";
8
+ import { insertQuoteMarkers } from "../util/quote-utils.js";
8
9
 
9
10
  interface MessageWithQuotes extends Message {
10
11
  _quotes: Quote[];
@@ -28,24 +29,6 @@ function buildMessageText(message: Message): string {
28
29
  return parts.join('\n\n');
29
30
  }
30
31
 
31
- function insertQuoteMarkers(content: string, quotes: Quote[]): string {
32
- const validQuotes = quotes
33
- .filter(q => q.end !== null && q.end !== undefined)
34
- .sort((a, b) => b.end! - a.end!);
35
-
36
- let result = content;
37
- for (const quote of validQuotes) {
38
- let insertPos = quote.end!;
39
- if (insertPos >= 0 && insertPos <= result.length) {
40
- while (insertPos > 0 && (result[insertPos - 1] === '\n' || result[insertPos - 1] === ' ')) {
41
- insertPos--;
42
- }
43
- result = result.slice(0, insertPos) + "\u207a" + result.slice(insertPos);
44
- }
45
- }
46
- return result;
47
- }
48
-
49
32
  let instanceId = 0;
50
33
 
51
34
  export function MessageList() {
@@ -0,0 +1,121 @@
1
+ import { useKeyboard } from "@opentui/solid";
2
+ import { For, createSignal, onMount, onCleanup } from "solid-js";
3
+ import { useKeyboardNav } from "../context/keyboard.js";
4
+ import type { KeyEvent } from "@opentui/core";
5
+
6
+ export interface PersonPickerItem {
7
+ id: string;
8
+ name: string;
9
+ relationship?: string;
10
+ description?: string;
11
+ }
12
+
13
+ interface PersonPickerOverlayProps {
14
+ title: string;
15
+ people: PersonPickerItem[];
16
+ onSelect: (person: PersonPickerItem) => void;
17
+ onDismiss: () => void;
18
+ }
19
+
20
+ const truncate = (text: string, maxLen: number): string => {
21
+ return text.length > maxLen ? text.slice(0, maxLen - 3) + "..." : text;
22
+ };
23
+
24
+ export function PersonPickerOverlay(props: PersonPickerOverlayProps) {
25
+ const { setOverlayActive } = useKeyboardNav();
26
+ onMount(() => setOverlayActive(true));
27
+ onCleanup(() => setOverlayActive(false));
28
+
29
+ const [selectedIndex, setSelectedIndex] = createSignal(0);
30
+
31
+ useKeyboard((event: KeyEvent) => {
32
+ const key = event.name;
33
+ const listLength = props.people.length;
34
+
35
+ if (key === "j" || key === "down") {
36
+ event.preventDefault();
37
+ setSelectedIndex((prev) => Math.min(prev + 1, listLength - 1));
38
+ return;
39
+ }
40
+
41
+ if (key === "k" || key === "up") {
42
+ event.preventDefault();
43
+ setSelectedIndex((prev) => Math.max(prev - 1, 0));
44
+ return;
45
+ }
46
+
47
+ if (key === "return") {
48
+ event.preventDefault();
49
+ if (listLength > 0) {
50
+ props.onSelect(props.people[selectedIndex()]);
51
+ }
52
+ return;
53
+ }
54
+
55
+ if (key === "escape") {
56
+ event.preventDefault();
57
+ props.onDismiss();
58
+ return;
59
+ }
60
+ });
61
+
62
+ return (
63
+ <box
64
+ position="absolute"
65
+ width="100%"
66
+ height="100%"
67
+ left={0}
68
+ top={0}
69
+ backgroundColor="#000000"
70
+ alignItems="center"
71
+ justifyContent="center"
72
+ >
73
+ <box
74
+ width={70}
75
+ height="80%"
76
+ backgroundColor="#1a1a2e"
77
+ borderStyle="single"
78
+ borderColor="#586e75"
79
+ padding={2}
80
+ flexDirection="column"
81
+ >
82
+ <text fg="#eee8d5" marginBottom={1}>
83
+ {props.title}
84
+ </text>
85
+
86
+ <scrollbox height="100%">
87
+ <For each={props.people}>
88
+ {(person, index) => {
89
+ const isSelected = () => selectedIndex() === index();
90
+ const label = () => {
91
+ let text = person.name;
92
+ if (person.relationship) {
93
+ text += ` — ${person.relationship}`;
94
+ }
95
+ if (person.description) {
96
+ text += ` · ${truncate(person.description, 40)}`;
97
+ }
98
+ return text;
99
+ };
100
+
101
+ return (
102
+ <box
103
+ backgroundColor={isSelected() ? "#2d3748" : "transparent"}
104
+ paddingLeft={1}
105
+ paddingRight={1}
106
+ >
107
+ <text fg={isSelected() ? "#eee8d5" : "#839496"}>
108
+ {label()}
109
+ </text>
110
+ </box>
111
+ );
112
+ }}
113
+ </For>
114
+ </scrollbox>
115
+
116
+ <text> </text>
117
+ <text fg="#586e75">j/k: navigate | Enter: select | Esc: cancel</text>
118
+ </box>
119
+ </box>
120
+ );
121
+ }