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
@@ -1,6 +1,7 @@
1
1
  import { useKeyboard } from "@opentui/solid";
2
- import { For, createSignal, createMemo } from "solid-js";
2
+ import { For, createSignal, createMemo, onMount, onCleanup } from "solid-js";
3
3
  import type { KeyEvent } from "@opentui/core";
4
+ import { useKeyboardNav } from "../context/keyboard.js";
4
5
 
5
6
  export interface ToolkitListItem {
6
7
  id: string;
@@ -17,6 +18,9 @@ interface ToolkitListOverlayProps {
17
18
  }
18
19
 
19
20
  export function ToolkitListOverlay(props: ToolkitListOverlayProps) {
21
+ const { setOverlayActive } = useKeyboardNav();
22
+ onMount(() => setOverlayActive(true));
23
+ onCleanup(() => setOverlayActive(false));
20
24
  const [selectedIndex, setSelectedIndex] = createSignal(0);
21
25
  const [filterText, setFilterText] = createSignal("");
22
26
  const [filterMode, setFilterMode] = createSignal(false);
@@ -1,10 +1,16 @@
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 WelcomeOverlayProps {
4
6
  onDismiss: () => void;
5
7
  }
6
8
 
7
9
  export function WelcomeOverlay(props: WelcomeOverlayProps) {
10
+ const { setOverlayActive } = useKeyboardNav();
11
+ onMount(() => setOverlayActive(true));
12
+ onCleanup(() => setOverlayActive(false));
13
+
8
14
  useKeyboard((event) => {
9
15
  event.preventDefault();
10
16
  props.onDismiss();
@@ -6,6 +6,7 @@ import {
6
6
  Match,
7
7
  Switch,
8
8
  createSignal,
9
+ createMemo,
9
10
  type ParentComponent,
10
11
  } from "solid-js";
11
12
  import { createStore } from "solid-js/store";
@@ -34,6 +35,7 @@ import type {
34
35
  LLMRequest,
35
36
  } from "../../../src/core/types.js";
36
37
  import type { ToolProvider, ToolDefinition } from "../../../src/core/types.js";
38
+ import type { RoomSummary, RoomEntity, RoomMessage, RoomCreationInput } from "../../../src/core/types.js";
37
39
 
38
40
  interface EiStore {
39
41
  ready: boolean;
@@ -43,6 +45,11 @@ interface EiStore {
43
45
  messages: Message[];
44
46
  queueStatus: QueueStatus;
45
47
  notification: { message: string; level: "error" | "warn" | "info" } | null;
48
+ rooms: RoomSummary[];
49
+ activeRoomId: string | null;
50
+ roomMessages: RoomMessage[];
51
+ roomActivePath: RoomMessage[];
52
+ isRoomProcessing: boolean;
46
53
  }
47
54
 
48
55
  export interface EiContextValue {
@@ -108,6 +115,30 @@ export interface EiContextValue {
108
115
  updateTool: (id: string, updates: Partial<Omit<ToolDefinition, 'id' | 'created_at'>>) => Promise<boolean>;
109
116
  queueUserDedup: (itemType: "topic" | "person", entityIds: string[]) => void;
110
117
  cleanupTimers: () => void;
118
+ rooms: () => RoomSummary[];
119
+ activeRoomId: () => string | null;
120
+ roomMessages: () => RoomMessage[];
121
+ roomActivePath: () => RoomMessage[];
122
+ isRoomProcessing: () => boolean;
123
+ selectRoom: (roomId: string) => void;
124
+ resolveRoomName: (nameOrAlias: string) => string | null;
125
+ getRoom: (roomId: string) => RoomEntity | null;
126
+ createRoom: (input: RoomCreationInput) => Promise<string>;
127
+ updateRoom: (roomId: string, updates: Partial<RoomEntity>) => Promise<void>;
128
+ archiveRoom: (roomId: string) => Promise<void>;
129
+ deleteRoom: (roomId: string) => Promise<void>;
130
+ sendFfaMessage: (content: string | null, silenceReason?: string) => Promise<void>;
131
+ submitHumanRoomMessage: (content: string | null, silenceReason?: string) => string | null;
132
+ recallHumanRoomMessage: () => boolean;
133
+ activateRoom: () => Promise<void>;
134
+ selectCYPBranch: (messageId: string) => Promise<void>;
135
+ markAllRoomMessagesRead: () => Promise<number>;
136
+ captureRoom: () => void;
137
+ capturePersona: () => void;
138
+ sendSilenceMessage: (silenceReason?: string) => Promise<void>;
139
+ humanRoomMessagePending: () => boolean;
140
+ getArchivedRooms: () => RoomSummary[];
141
+ generatePersonaPreview: (name: string, description: string, relationship?: string, personaId?: string) => Promise<import('../../../src/prompts/generation/types.js').PersonaGenerationResult>;
111
142
  }
112
143
  const EiContext = createContext<EiContextValue>();
113
144
 
@@ -120,6 +151,11 @@ export const EiProvider: ParentComponent = (props) => {
120
151
  messages: [],
121
152
  queueStatus: { state: "idle", pending_count: 0, dlq_count: 0 },
122
153
  notification: null,
154
+ rooms: [],
155
+ activeRoomId: null,
156
+ roomMessages: [],
157
+ roomActivePath: [],
158
+ isRoomProcessing: false,
123
159
  });
124
160
 
125
161
  const [contextBoundarySignal, setContextBoundarySignal] = createSignal<string | undefined>(undefined);
@@ -173,6 +209,7 @@ export const EiProvider: ParentComponent = (props) => {
173
209
  };
174
210
 
175
211
  const selectPersona = (personaId: string) => {
212
+ setStore("activeRoomId", null);
176
213
  // Mark previous persona as read ONLY if we dwelled there 5+ seconds
177
214
  const previousId = store.activePersonaId;
178
215
  if (previousId && previousId === dwelledPersona && processor) {
@@ -266,6 +303,11 @@ export const EiProvider: ParentComponent = (props) => {
266
303
  return await processor.createPersona(input);
267
304
  };
268
305
 
306
+ const generatePersonaPreview = async (name: string, description: string, relationship?: string, personaId?: string) => {
307
+ if (!processor) throw new Error("Processor not ready");
308
+ return processor.generatePersonaPreview(name, description, relationship, personaId);
309
+ };
310
+
269
311
  const archivePersona = async (personaId: string) => {
270
312
  if (!processor) return;
271
313
  await processor.archivePersona(personaId);
@@ -456,7 +498,168 @@ export const EiProvider: ParentComponent = (props) => {
456
498
  return processor.searchHumanData(query, options);
457
499
  };
458
500
 
459
- // Post-start initialization: refresh UI state, select first persona, detect LLM
501
+ const refreshRooms = async () => {
502
+ if (!processor) return;
503
+ const list = processor.getRoomList();
504
+ setStore("rooms", list);
505
+ };
506
+
507
+ const refreshRoomMessages = async () => {
508
+ if (!processor) return;
509
+ const currentRoomId = store.activeRoomId;
510
+ if (!currentRoomId) return;
511
+ const msgs = processor.getRoomMessages(currentRoomId);
512
+ setStore("roomMessages", [...msgs]);
513
+ };
514
+
515
+ const refreshRoomActivePath = () => {
516
+ if (!processor) return;
517
+ const roomId = store.activeRoomId;
518
+ if (!roomId) return;
519
+ const path = processor.getRoomActivePath(roomId);
520
+ setStore("roomActivePath", [...path]);
521
+ };
522
+
523
+ const selectRoom = (roomId: string) => {
524
+ setStore("activeRoomId", roomId);
525
+ setStore("roomMessages", []);
526
+ setStore("roomActivePath", []);
527
+ setStore("isRoomProcessing", false);
528
+ if (processor) {
529
+ const msgs = processor.getRoomMessages(roomId);
530
+ setStore("roomMessages", [...msgs]);
531
+ refreshRoomActivePath();
532
+ if (readTimer) clearTimeout(readTimer);
533
+ readTimer = setTimeout(async () => {
534
+ if (store.activeRoomId === roomId && processor) {
535
+ await processor.markAllRoomMessagesRead(roomId);
536
+ await refreshRooms();
537
+ }
538
+ readTimer = null;
539
+ }, 5000);
540
+ }
541
+ };
542
+
543
+ const sendFfaMessage = async (content: string | null, silenceReason?: string) => {
544
+ const roomId = store.activeRoomId;
545
+ if (!roomId || !processor) return;
546
+ await processor.sendFfaMessage(roomId, content, silenceReason);
547
+ await refreshRooms();
548
+ };
549
+
550
+ const submitHumanRoomMessage = (content: string | null, silenceReason?: string): string | null => {
551
+ const roomId = store.activeRoomId;
552
+ if (!roomId || !processor) return null;
553
+ const msgId = processor.submitHumanRoomMessage(roomId, content, silenceReason);
554
+ void refreshRoomMessages();
555
+ return msgId;
556
+ };
557
+
558
+ const recallHumanRoomMessage = (): boolean => {
559
+ const roomId = store.activeRoomId;
560
+ if (!roomId || !processor) return false;
561
+ const recalled = processor.recallHumanRoomMessage(roomId);
562
+ void refreshRoomMessages();
563
+ return recalled;
564
+ };
565
+
566
+ const activateRoom = async () => {
567
+ const roomId = store.activeRoomId;
568
+ if (!roomId || !processor) return;
569
+ await processor.activateRoom(roomId);
570
+ await refreshRoomMessages();
571
+ void refreshRoomActivePath();
572
+ void refreshRooms();
573
+ };
574
+
575
+ const selectCYPBranch = async (messageId: string) => {
576
+ const roomId = store.activeRoomId;
577
+ if (!roomId || !processor) return;
578
+ await processor.selectCYPBranch(roomId, messageId);
579
+ await refreshRoomMessages();
580
+ await refreshRoomActivePath();
581
+ void refreshRooms();
582
+ };
583
+
584
+ const createRoom = async (input: RoomCreationInput): Promise<string> => {
585
+ if (!processor) return "";
586
+ const roomId = await processor.createRoom(input);
587
+ await refreshRooms();
588
+ selectRoom(roomId);
589
+ return roomId;
590
+ };
591
+
592
+ const updateRoom = async (roomId: string, updates: Partial<RoomEntity>) => {
593
+ if (!processor) return;
594
+ await processor.updateRoom(roomId, updates);
595
+ await refreshRooms();
596
+ if (roomId === store.activeRoomId) await refreshRoomMessages();
597
+ };
598
+
599
+ const archiveRoom = async (roomId: string) => {
600
+ if (!processor) return;
601
+ await processor.archiveRoom(roomId);
602
+ if (store.activeRoomId === roomId) setStore("activeRoomId", null);
603
+ await refreshRooms();
604
+ };
605
+
606
+ const deleteRoom = async (roomId: string) => {
607
+ if (!processor) return;
608
+ await processor.deleteRoom(roomId);
609
+ if (store.activeRoomId === roomId) setStore("activeRoomId", null);
610
+ await refreshRooms();
611
+ };
612
+
613
+ const markAllRoomMessagesRead = async (): Promise<number> => {
614
+ const roomId = store.activeRoomId;
615
+ if (!roomId || !processor) return 0;
616
+ const count = await processor.markAllRoomMessagesRead(roomId);
617
+ await refreshRooms();
618
+ return count;
619
+ };
620
+
621
+ const captureRoom = () => {
622
+ const roomId = store.activeRoomId;
623
+ if (!roomId || !processor) return;
624
+ processor.captureRoom(roomId);
625
+ };
626
+
627
+ const capturePersona = () => {
628
+ const personaId = store.activePersonaId;
629
+ if (!personaId || !processor) return;
630
+ processor.capturePersona(personaId);
631
+ };
632
+
633
+ const resolveRoomName = (nameOrAlias: string): string | null => {
634
+ if (!processor) return null;
635
+ return processor.resolveRoomName(nameOrAlias);
636
+ };
637
+
638
+ const getArchivedRooms = (): RoomSummary[] => {
639
+ if (!processor) return [];
640
+ return processor.getRoomList(true).filter(r => r.is_archived);
641
+ };
642
+
643
+ const getRoom = (roomId: string): RoomEntity | null => {
644
+ if (!processor) return null;
645
+ return processor.getRoom(roomId);
646
+ };
647
+
648
+ const humanRoomMessagePending = createMemo(() => {
649
+ const roomId = store.activeRoomId;
650
+ if (!roomId) return false;
651
+ const roomSummary = store.rooms.find(r => r.id === roomId);
652
+ if (!roomSummary?.active_node_id) return false;
653
+ return store.roomMessages.some(m => m.parent_id === roomSummary.active_node_id && m.role === "human");
654
+ });
655
+
656
+ const sendSilenceMessage = async (silenceReason?: string) => {
657
+ const currentId = store.activePersonaId;
658
+ if (!currentId || !processor) return;
659
+ await processor.sendMessage(currentId, null, silenceReason);
660
+ await refreshPersonas();
661
+ };
662
+
460
663
  async function finishBootstrap() {
461
664
  if (!processor) return;
462
665
 
@@ -474,6 +677,7 @@ export const EiProvider: ParentComponent = (props) => {
474
677
  }
475
678
  }
476
679
  await refreshPersonas();
680
+ await refreshRooms();
477
681
  logger.debug(`refreshPersonas done, count: ${store.personas.length}`);
478
682
  const status = await processor.getQueueStatus();
479
683
  logger.debug("Initial getQueueStatus:", status);
@@ -589,6 +793,29 @@ export const EiProvider: ParentComponent = (props) => {
589
793
  logger.info("State conflict detected, waiting for user resolution");
590
794
  setConflictData(data);
591
795
  },
796
+ onRoomAdded: () => void refreshRooms(),
797
+ onRoomRemoved: () => void refreshRooms(),
798
+ onRoomUpdated: (roomId) => {
799
+ void refreshRooms();
800
+ if (roomId === store.activeRoomId) {
801
+ void refreshRoomMessages();
802
+ refreshRoomActivePath();
803
+ }
804
+ },
805
+ onRoomMessageAdded: (roomId) => {
806
+ void refreshRooms();
807
+ if (roomId === store.activeRoomId) {
808
+ void refreshRoomMessages();
809
+ refreshRoomActivePath();
810
+ setStore("isRoomProcessing", false);
811
+ }
812
+ },
813
+ onRoomMessageQueued: () => {
814
+ void refreshRooms();
815
+ },
816
+ onRoomMessageProcessing: (roomId) => {
817
+ if (roomId === store.activeRoomId) setStore("isRoomProcessing", true);
818
+ },
592
819
  };
593
820
  processor = new Processor(eiInterface);
594
821
  logger.debug("Processor created, calling start()");
@@ -671,6 +898,30 @@ export const EiProvider: ParentComponent = (props) => {
671
898
  updateTool,
672
899
  queueUserDedup,
673
900
  cleanupTimers,
901
+ rooms: () => store.rooms,
902
+ activeRoomId: () => store.activeRoomId,
903
+ roomMessages: () => store.roomMessages,
904
+ roomActivePath: () => store.roomActivePath,
905
+ isRoomProcessing: () => store.isRoomProcessing,
906
+ selectRoom,
907
+ resolveRoomName,
908
+ getRoom,
909
+ createRoom,
910
+ updateRoom,
911
+ archiveRoom,
912
+ deleteRoom,
913
+ sendFfaMessage,
914
+ submitHumanRoomMessage,
915
+ recallHumanRoomMessage,
916
+ activateRoom,
917
+ selectCYPBranch,
918
+ markAllRoomMessagesRead,
919
+ captureRoom,
920
+ capturePersona,
921
+ sendSilenceMessage,
922
+ humanRoomMessagePending,
923
+ getArchivedRooms,
924
+ generatePersonaPreview,
674
925
  };
675
926
  return (
676
927
  <Switch>
@@ -7,7 +7,7 @@ import {
7
7
  } from "solid-js";
8
8
  import { useKeyboard, useRenderer, useSelectionHandler } from "@opentui/solid";
9
9
  import type { ScrollBoxRenderable, KeyEvent, TextareaRenderable, CliRenderer } from "@opentui/core";
10
- import type { PersonaSummary } from "../../../src/core/types.js";
10
+ import type { PersonaSummary, RoomSummary } from "../../../src/core/types.js";
11
11
  import { useEi } from "./ei";
12
12
  import { logger } from "../util/logger";
13
13
  import { copyToClipboard } from "../util/clipboard";
@@ -25,6 +25,8 @@ interface KeyboardContextValue {
25
25
  exitApp: () => Promise<void>;
26
26
  renderer: CliRenderer;
27
27
  resetHistoryIndex: () => void;
28
+ overlayActive: () => boolean;
29
+ setOverlayActive: (active: boolean) => void;
28
30
  }
29
31
 
30
32
  const KeyboardContext = createContext<KeyboardContextValue>();
@@ -32,8 +34,9 @@ const KeyboardContext = createContext<KeyboardContextValue>();
32
34
  export const KeyboardProvider: ParentComponent = (props) => {
33
35
  const [focusedPanel, setFocusedPanel] = createSignal<Panel>("input");
34
36
  const [sidebarVisible, setSidebarVisible] = createSignal(true);
37
+ const [overlayActive, setOverlayActive] = createSignal(false);
35
38
  const renderer = useRenderer();
36
- const { queueStatus, abortCurrentOperation, resumeQueue, pauseQueue, personas, activePersonaId, selectPersona, saveAndExit, showNotification, messages, recallPendingMessages, cleanupTimers } = useEi();
39
+ const { queueStatus, abortCurrentOperation, resumeQueue, pauseQueue, personas, activePersonaId, selectPersona, saveAndExit, showNotification, messages, recallPendingMessages, cleanupTimers, rooms, activeRoomId, selectRoom } = useEi();
37
40
 
38
41
  let messageScrollRef: ScrollBoxRenderable | null = null;
39
42
  let textareaRef: TextareaRenderable | null = null;
@@ -76,13 +79,26 @@ export const KeyboardProvider: ParentComponent = (props) => {
76
79
  if (event.name === "tab") {
77
80
  event.preventDefault();
78
81
  if (textareaRef && textareaRef.plainText.length > 0) return;
79
-
82
+
83
+ if (activeRoomId()) {
84
+ const activeRooms = rooms().filter((r: RoomSummary) => !r.is_archived);
85
+ if (activeRooms.length <= 1) return;
86
+ const current = activeRoomId();
87
+ const currentIndex = activeRooms.findIndex((r: RoomSummary) => r.id === current);
88
+ let nextIndex: number;
89
+ if (event.shift) {
90
+ nextIndex = (currentIndex - 1 + activeRooms.length) % activeRooms.length;
91
+ } else {
92
+ nextIndex = (currentIndex + 1) % activeRooms.length;
93
+ }
94
+ selectRoom(activeRooms[nextIndex].id);
95
+ return;
96
+ }
97
+
80
98
  const unarchived = personas().filter((p: PersonaSummary) => !p.is_archived);
81
99
  if (unarchived.length <= 1) return;
82
-
83
100
  const current = activePersonaId();
84
101
  const currentIndex = unarchived.findIndex((p: PersonaSummary) => p.id === current);
85
-
86
102
  let nextIndex: number;
87
103
  if (event.shift) {
88
104
  nextIndex = (currentIndex - 1 + unarchived.length) % unarchived.length;
@@ -120,6 +136,7 @@ export const KeyboardProvider: ParentComponent = (props) => {
120
136
  }
121
137
 
122
138
  if (event.name === "escape") {
139
+ if (overlayActive()) return;
123
140
  event.preventDefault();
124
141
  const status = queueStatus();
125
142
 
@@ -137,7 +154,7 @@ export const KeyboardProvider: ParentComponent = (props) => {
137
154
  }
138
155
 
139
156
 
140
- if (event.name === "up" && !event.ctrl && !event.shift && !event.meta) {
157
+ if (event.name === "up" && !overlayActive() && !activeRoomId() && !event.ctrl && !event.shift && !event.meta) {
141
158
  if (!textareaRef) return;
142
159
  const cursor = textareaRef.logicalCursor;
143
160
  // Only intercept when cursor is at the very beginning (row 0, col 0)
@@ -174,7 +191,7 @@ export const KeyboardProvider: ParentComponent = (props) => {
174
191
  return;
175
192
  }
176
193
 
177
- if (event.name === "down" && !event.ctrl && !event.shift && !event.meta) {
194
+ if (event.name === "down" && !overlayActive() && !activeRoomId() && !event.ctrl && !event.shift && !event.meta) {
178
195
  if (!textareaRef || historyIndex === -1) return;
179
196
  // Only intercept when cursor is at the very end
180
197
  if (textareaRef.cursorOffset !== textareaRef.plainText.length) return;
@@ -199,7 +216,7 @@ export const KeyboardProvider: ParentComponent = (props) => {
199
216
 
200
217
  if (!messageScrollRef) return;
201
218
 
202
- const scrollAmount = messageScrollRef.height;
219
+ const scrollAmount = Math.floor(messageScrollRef.height / 2);
203
220
 
204
221
  if (event.name === "pageup") {
205
222
  event.preventDefault();
@@ -243,6 +260,8 @@ export const KeyboardProvider: ParentComponent = (props) => {
243
260
  exitApp,
244
261
  renderer,
245
262
  resetHistoryIndex,
263
+ overlayActive,
264
+ setOverlayActive,
246
265
  };
247
266
 
248
267
  return (
@@ -252,10 +271,27 @@ export const KeyboardProvider: ParentComponent = (props) => {
252
271
  );
253
272
  };
254
273
 
274
+ // No-op stub returned when useKeyboardNav is called outside KeyboardProvider.
275
+ // ConflictOverlay is the only legitimate caller in this situation — it renders
276
+ // before KeyboardProvider exists because the app can't boot (state conflict).
277
+ // All keyboard operations are genuinely meaningless at that point; the stub
278
+ // makes setOverlayActive calls harmless rather than fatal.
279
+ const KEYBOARD_NOOP: KeyboardContextValue = {
280
+ focusedPanel: () => "input" as Panel,
281
+ setFocusedPanel: () => {},
282
+ registerMessageScroll: () => {},
283
+ registerTextarea: () => {},
284
+ registerEditorHandler: () => {},
285
+ sidebarVisible: () => true,
286
+ toggleSidebar: () => {},
287
+ exitApp: async () => {},
288
+ renderer: null as unknown as CliRenderer,
289
+ resetHistoryIndex: () => {},
290
+ overlayActive: () => false,
291
+ setOverlayActive: () => {},
292
+ };
293
+
255
294
  export const useKeyboardNav = () => {
256
295
  const ctx = useContext(KeyboardContext);
257
- if (!ctx) {
258
- throw new Error("useKeyboardNav must be used within KeyboardProvider");
259
- }
260
- return ctx;
296
+ return ctx ?? KEYBOARD_NOOP;
261
297
  };
@@ -0,0 +1,152 @@
1
+ import { spawnEditor } from "./editor.js";
2
+ import type { CliRenderer } from "@opentui/core";
3
+ import type { RoomMessage, PersonaSummary } from "../../../src/core/types.js";
4
+
5
+ interface ParsedBlock {
6
+ id: string;
7
+ chosen: boolean;
8
+ }
9
+
10
+ export function buildCYPEditorYAML(
11
+ activeNodeId: string,
12
+ messages: RoomMessage[],
13
+ personas: PersonaSummary[]
14
+ ): string {
15
+ const children = messages.filter((m) => m.parent_id === activeNodeId);
16
+
17
+ const header = `# Choose Your Path
18
+ # Mark exactly ONE response with _chosen: true to continue the story.
19
+ # Save and exit to confirm your selection.
20
+
21
+ `;
22
+
23
+ const isExplored = (msgId: string) => messages.some((x) => x.parent_id === msgId);
24
+
25
+ const blocks = children.map((m) => {
26
+ let speaker = "You";
27
+ if (m.role === "persona" && m.persona_id) {
28
+ speaker = personas.find((p) => p.id === m.persona_id)?.display_name ?? m.persona_id;
29
+ }
30
+
31
+ const contentLines: string[] = [];
32
+ if (m.verbal_response !== undefined) {
33
+ const indented = m.verbal_response.split("\n").map((l) => ` ${l}`).join("\n");
34
+ contentLines.push(` verbal_response: |\n${indented}`);
35
+ }
36
+ if (m.action_response !== undefined) {
37
+ const indented = m.action_response.split("\n").map((l) => ` ${l}`).join("\n");
38
+ contentLines.push(` action_response: |\n${indented}`);
39
+ }
40
+ if (m.silence_reason !== undefined && m.verbal_response === undefined) {
41
+ contentLines.push(` silence_reason: "${m.silence_reason}"`);
42
+ }
43
+ if (contentLines.length === 0) {
44
+ contentLines.push(` # (no content)`);
45
+ }
46
+
47
+ return `- id: "${m.id}"
48
+ speaker: "${speaker}"
49
+ explored: ${isExplored(m.id)}
50
+ _chosen: false
51
+ ${contentLines.join("\n")}`;
52
+ });
53
+
54
+ return header + blocks.join("\n\n");
55
+ }
56
+
57
+ export function parseCYPEditorYAML(content: string): ParsedBlock[] {
58
+ const blocks: ParsedBlock[] = [];
59
+ let currentId: string | null = null;
60
+ let currentChosen = false;
61
+ let inContent = false;
62
+
63
+ for (const line of content.split("\n")) {
64
+ const trimmed = line.trim();
65
+
66
+ if (trimmed.startsWith("#") || trimmed === "") continue;
67
+
68
+ // New block starts with `- id:` at column 0
69
+ if (line.startsWith("- id:")) {
70
+ if (currentId !== null) {
71
+ blocks.push({ id: currentId, chosen: currentChosen });
72
+ }
73
+ const val = trimmed.slice("- id:".length).trim().replace(/^["']|["']$/g, "");
74
+ currentId = val;
75
+ currentChosen = false;
76
+ inContent = false;
77
+ continue;
78
+ }
79
+
80
+ // Content block lines are indented with 4 spaces — skip them
81
+ if (inContent && line.startsWith(" ")) {
82
+ continue;
83
+ }
84
+ inContent = false;
85
+
86
+ if (currentId !== null) {
87
+ if (trimmed.startsWith("_chosen:")) {
88
+ const val = trimmed.slice("_chosen:".length).trim().toLowerCase();
89
+ currentChosen = val === "true";
90
+ } else if (trimmed.startsWith("content:")) {
91
+ inContent = true;
92
+ }
93
+ }
94
+ }
95
+
96
+ if (currentId !== null) {
97
+ blocks.push({ id: currentId, chosen: currentChosen });
98
+ }
99
+
100
+ return blocks;
101
+ }
102
+
103
+ export async function openCYPEditor(opts: {
104
+ roomId: string;
105
+ activeNodeId: string;
106
+ messages: RoomMessage[];
107
+ activePath: RoomMessage[];
108
+ personas: PersonaSummary[];
109
+ selectBranch: (messageId: string) => Promise<void>;
110
+ showNotification: (msg: string, level: "error" | "warn" | "info") => void;
111
+ renderer: CliRenderer;
112
+ }): Promise<void> {
113
+ const { roomId, activeNodeId, messages, personas, selectBranch, showNotification, renderer } = opts;
114
+
115
+ const yamlContent = buildCYPEditorYAML(activeNodeId, messages, personas);
116
+
117
+ const result = await spawnEditor({
118
+ initialContent: yamlContent,
119
+ filename: `${roomId}-choose.yaml`,
120
+ renderer,
121
+ });
122
+
123
+ if (result.aborted) {
124
+ showNotification("Editor cancelled", "info");
125
+ return;
126
+ }
127
+
128
+ if (!result.success) {
129
+ showNotification("Editor failed to open", "error");
130
+ return;
131
+ }
132
+
133
+ if (result.content === null) {
134
+ showNotification("No changes made", "info");
135
+ return;
136
+ }
137
+
138
+ const blocks = parseCYPEditorYAML(result.content);
139
+ const chosenBlocks = blocks.filter((b) => b.chosen);
140
+
141
+ if (chosenBlocks.length === 0) {
142
+ showNotification("No response chosen — editor cancelled", "warn");
143
+ return;
144
+ }
145
+
146
+ if (chosenBlocks.length > 1) {
147
+ showNotification("Mark exactly one response — try again", "warn");
148
+ return;
149
+ }
150
+
151
+ await selectBranch(chosenBlocks[0].id);
152
+ }
@@ -0,0 +1,19 @@
1
+ import type { Quote } from "../../../src/core/types.js";
2
+
3
+ export function insertQuoteMarkers(content: string, quotes: Quote[]): string {
4
+ const validQuotes = quotes
5
+ .filter(q => q.end !== null && q.end !== undefined)
6
+ .sort((a, b) => b.end! - a.end!);
7
+
8
+ let result = content;
9
+ for (const quote of validQuotes) {
10
+ let insertPos = quote.end!;
11
+ if (insertPos >= 0 && insertPos <= result.length) {
12
+ while (insertPos > 0 && (result[insertPos - 1] === '\n' || result[insertPos - 1] === ' ')) {
13
+ insertPos--;
14
+ }
15
+ result = result.slice(0, insertPos) + "\u207a" + result.slice(insertPos);
16
+ }
17
+ }
18
+ return result;
19
+ }