ei-tui 0.1.24 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,6 +23,12 @@ export async function handleDedupCurate(
23
23
  const entity_ids = response.request.data.entity_ids as string[];
24
24
  const state = stateManager.getHuman();
25
25
 
26
+ // Validate entity_type
27
+ if (!entity_type || !['fact', 'trait', 'topic', 'person'].includes(entity_type)) {
28
+ console.error(`[Dedup] Invalid entity_type: "${entity_type}" (from request data)`, response.request.data);
29
+ return;
30
+ }
31
+
26
32
  // Parse Opus response
27
33
  let decisions: DedupResult;
28
34
  try {
@@ -43,8 +49,28 @@ export async function handleDedupCurate(
43
49
 
44
50
  console.log(`[Dedup] Processing cluster: ${decisions.update.length} updates, ${decisions.remove.length} removals, ${decisions.add.length} additions`);
45
51
 
46
- // HYDRATION: Fetch entities by ID (graceful degradation for missing)
47
- const entityList = state[`${entity_type}s` as 'facts' | 'traits' | 'topics' | 'people'];
52
+ // Map entity_type to pluralized state property name
53
+ const pluralMap: Record<DataItemType, 'facts' | 'traits' | 'topics' | 'people'> = {
54
+ fact: 'facts',
55
+ trait: 'traits',
56
+ topic: 'topics',
57
+ person: 'people'
58
+ };
59
+ const entityList = state[pluralMap[entity_type]];
60
+
61
+ // Validate entityList exists
62
+ if (!entityList || !Array.isArray(entityList)) {
63
+ console.error(`[Dedup] entityList is ${entityList === undefined ? 'undefined' : 'not an array'} for entity_type="${entity_type}" (looking for state.${entity_type}s)`, {
64
+ entity_type,
65
+ entity_ids,
66
+ stateKeys: Object.keys(state),
67
+ factsExists: !!state.facts,
68
+ traitsExists: !!state.traits,
69
+ topicsExists: !!state.topics,
70
+ peopleExists: !!state.people
71
+ });
72
+ return;
73
+ }
48
74
  const entities = entity_ids
49
75
  .map((id: string) => entityList.find((e: Fact | Trait | Topic | Person) => e.id === id))
50
76
  .filter((e: Fact | Trait | Topic | Person | undefined): e is (Fact | Trait | Topic | Person) => e !== undefined);
@@ -182,7 +182,6 @@ export function handleCeremonyProgress(state: StateManager, lastPhase: number):
182
182
  // Dedup phase complete → start Expose phase
183
183
  console.log("[ceremony:progress] Dedup complete, starting Expose phase");
184
184
 
185
- const human = state.getHuman();
186
185
  const personas = state.persona_getAll();
187
186
  const activePersonas = personas.filter(p =>
188
187
  !p.is_paused &&
@@ -190,17 +189,21 @@ export function handleCeremonyProgress(state: StateManager, lastPhase: number):
190
189
  !p.is_static
191
190
  );
192
191
 
193
- const lastCeremony = human.settings?.ceremony?.last_ceremony
194
- ? new Date(human.settings.ceremony.last_ceremony).getTime()
195
- : 0;
196
-
197
- const personasWithActivity = activePersonas.filter(p => {
198
- const lastActivity = p.last_activity ? new Date(p.last_activity).getTime() : 0;
199
- return lastActivity > lastCeremony;
192
+ // Find personas with unprocessed messages (any message with p/r/o/f = false)
193
+ const personasWithUnprocessed = activePersonas.filter(p => {
194
+ const messages = state.messages_get(p.id);
195
+ return messages.some(msg =>
196
+ !msg.p ||
197
+ !msg.r ||
198
+ !msg.o ||
199
+ !msg.f
200
+ );
200
201
  });
201
202
 
203
+ console.log(`[ceremony:expose] Found ${activePersonas.length} active personas, ${personasWithUnprocessed.length} with unprocessed messages`);
204
+
202
205
  const options: ExtractionOptions = { ceremony_progress: 2 };
203
- for (const persona of personasWithActivity) {
206
+ for (const persona of personasWithUnprocessed) {
204
207
  queueExposurePhase(persona.id, state, options);
205
208
  }
206
209
  return;
@@ -20,7 +20,7 @@ interface Cluster {
20
20
  // DEDUP CANDIDATE FINDING (copied from ceremony.ts)
21
21
  // =============================================================================
22
22
 
23
- const DEDUP_DEFAULT_THRESHOLD = 0.95;
23
+ const DEDUP_DEFAULT_THRESHOLD = 0.85; // Lowered from 0.95 based on experimental analysis: 0.95 only catches 3.9% of duplicate name groups, 0.85 catches 46.7%
24
24
 
25
25
  function findDedupCandidates<T extends DedupableItem>(
26
26
  items: T[],
@@ -127,6 +127,13 @@ function filterClusters(clusters: Cluster[]): Cluster[] {
127
127
 
128
128
  export function queueDedupPhase(state: StateManager): void {
129
129
  const human = state.getHuman();
130
+ const rewriteModel = human.settings?.rewrite_model;
131
+
132
+ if (!rewriteModel) {
133
+ console.log("[Dedup] rewrite_model not set — skipping dedup phase");
134
+ return;
135
+ }
136
+
130
137
  const threshold = human.settings?.ceremony?.dedup_threshold ?? DEDUP_DEFAULT_THRESHOLD;
131
138
 
132
139
  console.log(`[Dedup] Starting deduplication phase (threshold: ${threshold})`);
@@ -183,13 +190,13 @@ export function queueDedupPhase(state: StateManager): void {
183
190
  system: prompt.system,
184
191
  user: prompt.user,
185
192
  next_step: LLMNextStep.HandleDedupCurate,
193
+ model: rewriteModel,
186
194
  data: {
187
195
  entity_type: type,
188
- entity_ids: cluster.ids, // Lightweight stub (IDs only)
189
- ceremony_progress: 1 // Phase 1 (Dedup)
196
+ entity_ids: cluster.ids,
197
+ ceremony_progress: 1
190
198
  }
191
199
  });
192
-
193
200
  totalClusters++;
194
201
  }
195
202
  }
@@ -291,7 +291,7 @@ export class Processor {
291
291
  builtin: true,
292
292
  enabled: true,
293
293
  created_at: now,
294
- max_calls_per_interaction: 3,
294
+ max_calls_per_interaction: 6, // Dedup needs to verify relationships before irreversible merges. Typical cluster (3-8 items) requires: parent concept lookup + 2 relationship verifications + context validation. Still under HARD_TOOL_CALL_LIMIT (10).
295
295
  });
296
296
  }
297
297
 
@@ -715,14 +715,23 @@ const toolNextSteps = new Set([
715
715
  LLMNextStep.HandleHeartbeatCheck,
716
716
  LLMNextStep.HandleEiHeartbeat,
717
717
  LLMNextStep.HandleToolContinuation,
718
+ LLMNextStep.HandleDedupCurate,
718
719
  ]);
719
720
  const toolPersonaId =
720
721
  personaId ??
721
722
  (request.next_step === LLMNextStep.HandleEiHeartbeat ? "ei" : undefined);
722
- const tools =
723
- toolNextSteps.has(request.next_step) && toolPersonaId
724
- ? this.stateManager.tools_getForPersona(toolPersonaId, this.isTUI)
725
- : [];
723
+
724
+ // Dedup operates on Human data, not persona data - provide read_memory directly
725
+ let tools: ToolDefinition[] = [];
726
+ if (request.next_step === LLMNextStep.HandleDedupCurate) {
727
+ const readMemory = this.stateManager.tools_getByName("read_memory");
728
+ if (readMemory?.enabled) {
729
+ tools = [readMemory];
730
+ }
731
+ } else if (toolNextSteps.has(request.next_step) && toolPersonaId) {
732
+ tools = this.stateManager.tools_getForPersona(toolPersonaId, this.isTUI);
733
+ }
734
+
726
735
  console.log(
727
736
  `[Tools] Dispatch for ${request.next_step} persona=${toolPersonaId ?? "none"}: ${tools.length} tool(s) attached`
728
737
  );
@@ -15,22 +15,56 @@ import type { DedupPromptData } from "./types.js";
15
15
  export function buildDedupPrompt(data: DedupPromptData): { system: string; user: string } {
16
16
  const typeLabel = data.itemType.charAt(0).toUpperCase() + data.itemType.slice(1);
17
17
 
18
- const system = `You are acting as the curator for a user's internal database. You have been given a cluster of ${typeLabel} records that our system believes may be duplicates (based on semantic similarity >= 0.90).
18
+ const system = `## HARD RULES (Non-Negotiable Override All Other Instructions)
19
+
20
+ You are working with Opus 4.6 constraints. These rules prevent overthinking and ensure decisive action:
21
+
22
+ ### 1. TOOL BUDGET
23
+ - You have **6 \`read_memory\` calls** for this cluster
24
+ - Prioritize: verify ambiguous relationships > check parent concepts > validate new entities
25
+ - After 6 calls, make decisions with available information
26
+ - Do NOT waste calls re-checking pairs you already examined
27
+
28
+ ### 2. SATISFICING MODE (Good Enough > Perfect)
29
+ - If two items share **85%+ semantic similarity** on core meaning → merge them
30
+ - Do NOT re-examine after deciding to merge
31
+ - Do NOT explore alternative groupings
32
+ - First valid match wins — stop searching for "better" options
33
+
34
+ ### 3. FORBIDDEN PATTERNS (Signs of Overthinking)
35
+ If you find yourself writing these phrases, **STOP IMMEDIATELY**:
36
+ - ❌ "On the other hand..." / "However, there's another angle..."
37
+ - ❌ "Let me reconsider..." / "But what if..."
38
+ - ❌ "This could be interpreted as..."
39
+ - ❌ Re-analyzing the same pair after making a decision
40
+
41
+ Output format when you catch overthinking:
42
+ \`\`\`
43
+ [OVERTHINKING DETECTED]
44
+ Decision: [Yes/No to merge]
45
+ Reason: [1 sentence]
46
+ \`\`\`
47
+
48
+ ---
49
+
50
+ ## YOUR TASK
51
+
52
+ You are acting as the curator for a user's internal database. You have been given a cluster of ${typeLabel} records that our system believes may be duplicates (based on semantic similarity >= 0.90).
19
53
 
20
54
  **YOUR PRIME DIRECTIVE IS TO LOSE _NO_ DATA.**
21
55
 
22
56
  Your secondary directive is to ORGANIZE IT into small, non-repetitive components. The user NEEDS the data, but the data is used by AI agents, so duplication limits usefulness—agents waste tokens re-reading the same information under different names.
23
57
 
24
- You have access to a tool called \`read_memory\` which will query the user's internal system for additional context if needed. Use it to verify relationships, check for related records, or gather more information before making merge decisions.
58
+ You have access to a tool called \`read_memory\` (6 calls max see HARD RULES above). Use it strategically to verify relationships, check for related records, or gather context before making merge decisions.
25
59
 
26
- Your task:
27
- 1. **Identify true duplicates**: Examine each record. Are these genuinely the same thing with different wording, or are they distinct but related concepts?
60
+ ### Decision Process:
61
+ 1. **Identify true duplicates**: Examine each record. Are these genuinely the same thing with different wording (85%+ core meaning overlap), or are they distinct but related concepts?
28
62
  2. **Merge where appropriate**: For TRUE duplicates, consolidate all unique information into ONE canonical record. Pick the best "name" (most descriptive, most commonly used). Merge all descriptions—every unique detail must be preserved.
29
63
  3. **Keep distinct concepts separate**: Similar ≠ duplicate. "Software Engineering" and "Software Architecture" may be related but are NOT the same. "Job at Company X" and "Profession: Software Engineer" are related but distinct. Do NOT merge these.
30
64
  4. **Track what was merged**: For removed records, indicate which record absorbed their data (via "replaced_by" field).
31
65
  5. **Add new records if needed**: If consolidating reveals a MISSING intermediate concept (e.g., merging "Python Developer" and "Backend Engineer" reveals we're missing "Software Engineering" as a parent topic), create it.
32
66
 
33
- The format of your final output should be:
67
+ ### Output Format:
34
68
  {
35
69
  "update": [
36
70
  /* Full ${typeLabel} record payloads with all fields preserved */
@@ -53,14 +87,14 @@ Record format for "${typeLabel}" (based on type):
53
87
 
54
88
  ${buildRecordFormatExamples(data.itemType)}
55
89
 
56
- Rules:
90
+ ### Rules:
57
91
  - Do NOT invent information. Only redistribute what exists in the cluster.
58
92
  - Descriptions should be concise—ideally under 300 characters, never over 500.
59
93
  - Preserve all numeric values (sentiment, strength, confidence, exposure, etc.) from source records. When merging, take the HIGHER value for strength/confidence, AVERAGE for sentiment.
60
94
  - Every removed record MUST have "replaced_by" pointing to the canonical record that absorbed its data.
61
95
  - The "update" array should contain AT LEAST ONE record (the canonical/merged one), even if all others are removed.
62
96
  - If records are NOT duplicates (just similar), return them ALL in "update" unchanged, with empty "remove" and "add" arrays.
63
- - Use \`read_memory\` to check for related records or gather context before making irreversible merge decisions.`;
97
+ - Use \`read_memory\` strategically (6 calls max) to check for related records or gather context before making irreversible merge decisions.`;
64
98
 
65
99
  const user = JSON.stringify({
66
100
  cluster: data.cluster.map(stripEmbedding),
package/tui/src/app.tsx CHANGED
@@ -10,23 +10,25 @@ import { StatusBar } from "./components/StatusBar";
10
10
  import { Show } from "solid-js";
11
11
  import { useEi } from "./context/ei";
12
12
  import { WelcomeOverlay } from "./components/WelcomeOverlay";
13
+ import { useRenderer } from "@opentui/solid";
13
14
 
14
15
  function AppContent() {
15
- const { overlayRenderer, hideOverlay, showOverlay } = useOverlay();
16
+ const { overlayRenderer, showOverlay } = useOverlay();
16
17
  const { showWelcomeOverlay, dismissWelcomeOverlay } = useEi();
17
-
18
+ const renderer = useRenderer();
18
19
  // Show welcome overlay when LLM detection determines no provider is configured
19
20
  createEffect(() => {
20
21
  if (showWelcomeOverlay()) {
21
- showOverlay((onDismiss) => (
22
+ showOverlay((onDismiss, _hideForEditor) => (
22
23
  <WelcomeOverlay onDismiss={() => {
23
24
  dismissWelcomeOverlay();
24
25
  onDismiss();
25
26
  }} />
26
- ));
27
+ ), renderer);
27
28
  }
28
29
  });
29
30
 
31
+
30
32
  return (
31
33
  <box flexDirection="column" width="100%" height="100%">
32
34
  <Layout
@@ -36,7 +38,7 @@ function AppContent() {
36
38
  />
37
39
  <StatusBar />
38
40
  <Show when={overlayRenderer()}>
39
- {overlayRenderer()!(hideOverlay)}
41
+ {overlayRenderer()!()}
40
42
  </Show>
41
43
  </box>
42
44
  );
@@ -16,7 +16,7 @@ export const archiveCommand: Command = {
16
16
  ctx.showNotification("No archived personas", "info");
17
17
  return;
18
18
  }
19
- ctx.showOverlay((hideOverlay) => (
19
+ ctx.showOverlay((hideOverlay, _hideForEditor) => (
20
20
  <PersonaListOverlay
21
21
  personas={archived}
22
22
  activePersonaId={null}
@@ -30,7 +30,7 @@ export const archiveCommand: Command = {
30
30
  }}
31
31
  onDismiss={hideOverlay}
32
32
  />
33
- ));
33
+ ), ctx.renderer);
34
34
  return;
35
35
  }
36
36
 
@@ -90,12 +90,12 @@ export const contextCommand: Command = {
90
90
  });
91
91
 
92
92
  const shouldReEdit = await new Promise<boolean>((resolve) => {
93
- ctx.showOverlay((hideOverlay) => (
93
+ ctx.showOverlay((hideOverlay, hideForEditor) => (
94
94
  <ConfirmOverlay
95
95
  message={`YAML parse error:\n${errorMsg}\n\nRe-edit?`}
96
96
  onConfirm={() => {
97
97
  logger.debug("[context] user confirmed re-edit");
98
- hideOverlay();
98
+ hideForEditor();
99
99
  resolve(true);
100
100
  }}
101
101
  onCancel={() => {
@@ -104,7 +104,7 @@ export const contextCommand: Command = {
104
104
  resolve(false);
105
105
  }}
106
106
  />
107
- ));
107
+ ), ctx.renderer);
108
108
  });
109
109
 
110
110
  logger.debug("[context] shouldReEdit", { shouldReEdit, iteration: editorIteration });
@@ -112,7 +112,6 @@ export const contextCommand: Command = {
112
112
  if (shouldReEdit) {
113
113
  yamlContent = result.content;
114
114
  logger.debug("[context] continuing to next iteration");
115
- await new Promise((r) => setTimeout(r, 50));
116
115
  continue;
117
116
  } else {
118
117
  ctx.showNotification("Changes discarded", "info");
@@ -14,13 +14,13 @@ export const deleteCommand: Command = {
14
14
 
15
15
  const confirmAndDelete = async (personaId: string, displayName: string) => {
16
16
  const confirmed = await new Promise<boolean>((resolve) => {
17
- ctx.showOverlay((hideOverlay) => (
17
+ ctx.showOverlay((hideOverlay, _hideForEditor) => (
18
18
  <ConfirmOverlay
19
19
  message={`Delete "${displayName}"?\nThis cannot be undone.`}
20
20
  onConfirm={() => { hideOverlay(); resolve(true); }}
21
21
  onCancel={() => { hideOverlay(); resolve(false); }}
22
22
  />
23
- ));
23
+ ), ctx.renderer);
24
24
  });
25
25
 
26
26
  if (confirmed) {
@@ -36,7 +36,7 @@ export const deleteCommand: Command = {
36
36
  ctx.showNotification("No personas available to delete", "info");
37
37
  return;
38
38
  }
39
- ctx.showOverlay((hideOverlay) => (
39
+ ctx.showOverlay((hideOverlay, _hideForEditor) => (
40
40
  <PersonaListOverlay
41
41
  personas={deletable}
42
42
  activePersonaId={null}
@@ -48,7 +48,7 @@ export const deleteCommand: Command = {
48
48
  }}
49
49
  onDismiss={hideOverlay}
50
50
  />
51
- ));
51
+ ), ctx.renderer);
52
52
  return;
53
53
  }
54
54
 
@@ -52,18 +52,17 @@ export const dlqCommand: Command = {
52
52
  const errorMsg = parseError instanceof Error ? parseError.message : String(parseError);
53
53
 
54
54
  const shouldReEdit = await new Promise<boolean>((resolve) => {
55
- ctx.showOverlay((hideOverlay) =>
55
+ ctx.showOverlay((hideOverlay, hideForEditor) =>
56
56
  ConfirmOverlay({
57
57
  message: `YAML error:\n${errorMsg}\n\nRe-edit?`,
58
- onConfirm: () => { hideOverlay(); resolve(true); },
58
+ onConfirm: () => { hideForEditor(); resolve(true); },
59
59
  onCancel: () => { hideOverlay(); resolve(false); },
60
60
  })
61
- );
61
+ , ctx.renderer);
62
62
  });
63
63
 
64
64
  if (shouldReEdit) {
65
65
  yamlContent = result.content;
66
- await new Promise(r => setTimeout(r, 50));
67
66
  continue;
68
67
  }
69
68
 
@@ -7,6 +7,6 @@ export const helpCommand: Command = {
7
7
  description: "Show help screen",
8
8
  usage: "/help or /h",
9
9
  execute: async (_args, ctx) => {
10
- ctx.showOverlay((hideOverlay) => <HelpOverlay onDismiss={hideOverlay} />);
10
+ ctx.showOverlay((_hideOverlay, _hideForEditor) => <HelpOverlay onDismiss={_hideOverlay} />, ctx.renderer);
11
11
  },
12
12
  };
@@ -112,12 +112,12 @@ export const meCommand: Command = {
112
112
  logger.debug("[me] YAML parse error, prompting for re-edit", { iteration: editorIteration, error: errorMsg });
113
113
 
114
114
  const shouldReEdit = await new Promise<boolean>((resolve) => {
115
- ctx.showOverlay((hideOverlay) => (
115
+ ctx.showOverlay((hideOverlay, hideForEditor) => (
116
116
  <ConfirmOverlay
117
117
  message={`YAML parse error:\n${errorMsg}\n\nRe-edit?`}
118
118
  onConfirm={() => {
119
119
  logger.debug("[me] user confirmed re-edit");
120
- hideOverlay();
120
+ hideForEditor();
121
121
  resolve(true);
122
122
  }}
123
123
  onCancel={() => {
@@ -126,7 +126,7 @@ export const meCommand: Command = {
126
126
  resolve(false);
127
127
  }}
128
128
  />
129
- ));
129
+ ), ctx.renderer);
130
130
  });
131
131
 
132
132
  logger.debug("[me] shouldReEdit", { shouldReEdit, iteration: editorIteration });
@@ -134,7 +134,6 @@ export const meCommand: Command = {
134
134
  if (shouldReEdit) {
135
135
  yamlContent = result.content;
136
136
  logger.debug("[me] continuing to next iteration");
137
- await new Promise(r => setTimeout(r, 50));
138
137
  continue;
139
138
  } else {
140
139
  ctx.showNotification("Changes discarded", "info");
@@ -13,7 +13,7 @@ export const personaCommand: Command = {
13
13
  const unarchived = ctx.ei.personas().filter(p => !p.is_archived);
14
14
 
15
15
  if (args.length === 0) {
16
- ctx.showOverlay((hideOverlay) => (
16
+ ctx.showOverlay((hideOverlay, _hideForEditor) => (
17
17
  <PersonaListOverlay
18
18
  personas={unarchived}
19
19
  activePersonaId={ctx.ei.activePersonaId()}
@@ -25,7 +25,7 @@ export const personaCommand: Command = {
25
25
  }}
26
26
  onDismiss={hideOverlay}
27
27
  />
28
- ));
28
+ ), ctx.renderer);
29
29
  return;
30
30
  }
31
31
 
@@ -77,7 +77,7 @@ export const providerCommand: Command = {
77
77
  ctx.showNotification("No providers configured. Use /provider new to create one.", "info");
78
78
  return;
79
79
  }
80
- ctx.showOverlay((hideOverlay) => (
80
+ ctx.showOverlay((hideOverlay, hideForEditor) => (
81
81
  <ProviderListOverlay
82
82
  providers={providers}
83
83
  activeProviderKey={activeKey}
@@ -86,8 +86,7 @@ export const providerCommand: Command = {
86
86
  await setProviderOnPersona(provider.key, provider.defaultModel, ctx);
87
87
  }}
88
88
  onEdit={async (provider) => {
89
- hideOverlay();
90
- await new Promise(r => setTimeout(r, 50));
89
+ hideForEditor();
91
90
  const human = await ctx.ei.getHuman();
92
91
  const account = human.settings?.accounts?.find(a => a.id === provider.id);
93
92
  if (account) {
@@ -96,12 +95,11 @@ export const providerCommand: Command = {
96
95
  }}
97
96
  onNew={async () => {
98
97
  hideOverlay();
99
- await new Promise(r => setTimeout(r, 50));
100
98
  await createProviderViaEditor(ctx);
101
99
  }}
102
100
  onDismiss={hideOverlay}
103
101
  />
104
- ));
102
+ ), ctx.renderer);
105
103
  return;
106
104
  }
107
105
 
@@ -53,18 +53,17 @@ export const queueCommand: Command = {
53
53
  const errorMsg = parseError instanceof Error ? parseError.message : String(parseError);
54
54
 
55
55
  const shouldReEdit = await new Promise<boolean>((resolve) => {
56
- ctx.showOverlay((hideOverlay) =>
56
+ ctx.showOverlay((hideOverlay, hideForEditor) =>
57
57
  ConfirmOverlay({
58
58
  message: `YAML error:\n${errorMsg}\n\nRe-edit?`,
59
- onConfirm: () => { hideOverlay(); resolve(true); },
59
+ onConfirm: () => { hideForEditor(); resolve(true); },
60
60
  onCancel: () => { hideOverlay(); resolve(false); },
61
61
  })
62
- );
62
+ , ctx.renderer);
63
63
  });
64
64
 
65
65
  if (shouldReEdit) {
66
66
  yamlContent = result.content;
67
- await new Promise(r => setTimeout(r, 50));
68
67
  continue;
69
68
  }
70
69
 
@@ -75,12 +75,12 @@ async function openQuotesInEditor(
75
75
  });
76
76
 
77
77
  const shouldReEdit = await new Promise<boolean>((resolve) => {
78
- ctx.showOverlay((hideOverlay) => (
78
+ ctx.showOverlay((hideOverlay, hideForEditor) => (
79
79
  <ConfirmOverlay
80
80
  message={`YAML parse error:\n${errorMsg}\n\nRe-edit?`}
81
81
  onConfirm={() => {
82
82
  logger.debug("[quotes] user confirmed re-edit");
83
- hideOverlay();
83
+ hideForEditor();
84
84
  resolve(true);
85
85
  }}
86
86
  onCancel={() => {
@@ -89,7 +89,7 @@ async function openQuotesInEditor(
89
89
  resolve(false);
90
90
  }}
91
91
  />
92
- ));
92
+ ), ctx.renderer);
93
93
  });
94
94
 
95
95
  logger.debug("[quotes] shouldReEdit", { shouldReEdit, iteration: editorIteration });
@@ -97,7 +97,6 @@ async function openQuotesInEditor(
97
97
  if (shouldReEdit) {
98
98
  yamlContent = result.content;
99
99
  logger.debug("[quotes] continuing to next iteration");
100
- await new Promise((r) => setTimeout(r, 50));
101
100
  continue;
102
101
  } else {
103
102
  ctx.showNotification("Changes discarded", "info");
@@ -146,14 +145,13 @@ export const quotesCommand: Command = {
146
145
  const allQuotes = await ctx.ei.getQuotes();
147
146
  const messageQuotes = allQuotes.filter(q => q.message_id === targetMessage.id);
148
147
 
149
- ctx.showOverlay((hide) => (
148
+ ctx.showOverlay((hide, hideForEditor) => (
150
149
  <QuotesOverlay
151
150
  quotes={messageQuotes}
152
151
  messageIndex={index}
153
152
  onClose={hide}
154
153
  onEdit={async () => {
155
- hide();
156
- await new Promise((r) => setTimeout(r, 50));
154
+ hideForEditor();
157
155
  await openQuotesInEditor(ctx, messageQuotes, `quotes from message [${index}]`);
158
156
  }}
159
157
  onDelete={async (quoteId) => {
@@ -161,7 +159,7 @@ export const quotesCommand: Command = {
161
159
  ctx.showNotification("Quote deleted", "info");
162
160
  }}
163
161
  />
164
- ));
162
+ ), ctx.renderer);
165
163
  return;
166
164
  }
167
165
 
@@ -11,7 +11,7 @@ export interface Command {
11
11
  }
12
12
 
13
13
  export interface CommandContext {
14
- showOverlay: (renderer: OverlayRenderer) => void;
14
+ showOverlay: (renderer: OverlayRenderer, cliRenderer?: CliRenderer) => void;
15
15
  hideOverlay: () => void;
16
16
  showNotification: (msg: string, level: "error" | "warn" | "info") => void;
17
17
  exitApp: () => Promise<void>;
@@ -16,7 +16,7 @@ export const setSyncCommand: Command = {
16
16
  const [username, passphrase] = args;
17
17
 
18
18
  const confirmed = await new Promise<boolean>((resolve) => {
19
- ctx.showOverlay((hideOverlay) => (
19
+ ctx.showOverlay((hideOverlay, _hideForEditor) => (
20
20
  <ConfirmOverlay
21
21
  message={`Set sync credentials for "${username}"?\n\nThis requires a restart. Just re-run ei once it closes!`}
22
22
  onConfirm={() => {
@@ -28,7 +28,7 @@ export const setSyncCommand: Command = {
28
28
  resolve(false);
29
29
  }}
30
30
  />
31
- ));
31
+ ), ctx.renderer);
32
32
  });
33
33
 
34
34
  if (!confirmed) {
@@ -54,11 +54,11 @@ export const settingsCommand: Command = {
54
54
  logger.debug("[settings] YAML parse error, prompting for re-edit", { iteration: editorIteration, error: errorMsg });
55
55
 
56
56
  const shouldReEdit = await new Promise<boolean>((resolve) => {
57
- ctx.showOverlay((hideOverlay) => (
57
+ ctx.showOverlay((hideOverlay, hideForEditor) => (
58
58
  <ConfirmOverlay
59
59
  message={`YAML parse error:\n${errorMsg}\n\nRe-edit?`}
60
60
  onConfirm={() => {
61
- hideOverlay();
61
+ hideForEditor();
62
62
  resolve(true);
63
63
  }}
64
64
  onCancel={() => {
@@ -66,12 +66,11 @@ export const settingsCommand: Command = {
66
66
  resolve(false);
67
67
  }}
68
68
  />
69
- ));
69
+ ), ctx.renderer);
70
70
  });
71
71
 
72
72
  if (shouldReEdit) {
73
73
  yamlContent = result.content;
74
- await new Promise(r => setTimeout(r, 50));
75
74
  continue;
76
75
  } else {
77
76
  ctx.showNotification("Changes discarded", "info");
@@ -45,7 +45,6 @@ export async function runSpotifyAuth(ctx: CommandContext): Promise<void> {
45
45
  const codePromise = waitForAuthCode(ctx);
46
46
 
47
47
  // Give the server a tick to bind its port before opening the browser
48
- await new Promise<void>((r) => setTimeout(r, 50));
49
48
  logger.info("[spotify-auth] Server should be up — opening browser now");
50
49
 
51
50
  // Open the authorization URL in the user's default browser
@@ -25,20 +25,19 @@ export const toolsCommand: Command = {
25
25
  toolCount: allTools.filter(t => t.provider_id === p.id).length,
26
26
  }));
27
27
 
28
- ctx.showOverlay((hideOverlay) => (
28
+ ctx.showOverlay((hideOverlay, hideForEditor) => (
29
29
  <ToolkitListOverlay
30
30
  toolkits={toolkits}
31
31
  onEdit={async (toolkit) => {
32
- hideOverlay();
33
- await new Promise(r => setTimeout(r, 50));
32
+ hideForEditor();
34
33
  const provider = providers.find(p => p.id === toolkit.id);
35
34
  if (provider) {
36
- const providerTools = allTools.filter(t => t.provider_id === provider.id);
35
+ const providerTools = allTools.filter(t => t.provider_id === toolkit.id);
37
36
  await openToolkitEditor(provider, providerTools, ctx);
38
37
  }
39
38
  }}
40
39
  onDismiss={hideOverlay}
41
40
  />
42
- ));
41
+ ), ctx.renderer);
43
42
  },
44
43
  };
@@ -6,24 +6,35 @@ import {
6
6
  type JSX,
7
7
  type Accessor,
8
8
  } from "solid-js";
9
+ import { type CliRenderer } from "@opentui/core";
9
10
  import { logger } from "../util/logger";
10
11
 
11
- export type OverlayRenderer = (hideOverlay: () => void) => JSX.Element;
12
+ export type OverlayRenderer = (hideOverlay: () => void, hideForEditor: () => void) => JSX.Element;
12
13
 
13
14
  interface OverlayContextValue {
14
- overlayRenderer: Accessor<OverlayRenderer | null>;
15
- showOverlay: (renderer: OverlayRenderer) => void;
15
+ overlayRenderer: Accessor<(() => JSX.Element) | null>;
16
+ showOverlay: (renderer: OverlayRenderer, cliRenderer?: CliRenderer) => void;
16
17
  hideOverlay: () => void;
17
18
  }
18
19
 
19
20
  const OverlayContext = createContext<OverlayContextValue>();
20
21
 
21
22
  export const OverlayProvider: ParentComponent = (props) => {
22
- const [overlayRenderer, setOverlayRenderer] = createSignal<OverlayRenderer | null>(null);
23
+ const [overlayRenderer, setOverlayRenderer] = createSignal<(() => JSX.Element) | null>(null);
23
24
 
24
- const showOverlay = (renderer: OverlayRenderer) => {
25
+ const showOverlay = (renderer: OverlayRenderer, cliRenderer?: CliRenderer) => {
25
26
  logger.debug("[overlay] showOverlay called");
26
- setOverlayRenderer(() => renderer);
27
+ const hideForEditor = () => {
28
+ if (cliRenderer) {
29
+ cliRenderer.currentRenderBuffer.clear();
30
+ }
31
+ setOverlayRenderer(null);
32
+ };
33
+ const hideOverlay = () => {
34
+ logger.debug("[overlay] hideOverlay called");
35
+ setOverlayRenderer(null);
36
+ };
37
+ setOverlayRenderer(() => () => renderer(hideOverlay, hideForEditor));
27
38
  };
28
39
 
29
40
  const hideOverlay = () => {
@@ -3,6 +3,7 @@ import * as fs from "fs";
3
3
  import * as os from "os";
4
4
  import * as path from "path";
5
5
  import type { CliRenderer } from "@opentui/core";
6
+ import { RendererControlState } from "@opentui/core";
6
7
  import { logger } from "./logger";
7
8
 
8
9
  export interface EditorOptions {
@@ -103,23 +104,30 @@ export async function spawnEditor(options: EditorOptions): Promise<EditorResult>
103
104
  const safeName = filename.replace(/\s+/g, "-");
104
105
  const tmpFile = path.join(tmpDir, `ei-${Date.now()}-${safeName}`);
105
106
 
106
- logger.debug("[editor] spawnEditor called", { filename, editor });
107
+ logger.debug("[editor] spawnEditor called - START", { filename, editor });
108
+
109
+ // CRITICAL: 50ms delay to let SolidJS reactive updates settle before suspending
110
+ // This prevents ghost frames when transitioning from overlays to editor
111
+ await new Promise(resolve => setTimeout(resolve, 50));
107
112
 
108
113
  fs.writeFileSync(tmpFile, initialContent, "utf-8");
109
114
  const originalContent = initialContent;
110
115
 
111
116
  return new Promise((resolve) => {
112
- logger.debug("[editor] calling renderer.suspend()");
113
- renderer.suspend();
114
- logger.debug("[editor] calling renderer.currentRenderBuffer.clear()");
115
- renderer.currentRenderBuffer.clear();
117
+ const wasAlreadySuspended = renderer.controlState === RendererControlState.EXPLICIT_SUSPENDED;
118
+ logger.debug("[editor] renderer state", { controlState: renderer.controlState, wasAlreadySuspended });
119
+
120
+ if (!wasAlreadySuspended) {
121
+ renderer.suspend();
122
+ }
116
123
 
117
- logger.debug("[editor] spawning editor process");
124
+ renderer.currentRenderBuffer.clear();
118
125
  const child = spawn(editor, [tmpFile], {
119
126
  stdio: "inherit",
120
127
  shell: true,
121
128
  });
122
129
 
130
+
123
131
  child.on("error", () => {
124
132
  logger.error("[editor] editor process error");
125
133
  renderer.currentRenderBuffer.clear();
@@ -135,13 +143,16 @@ export async function spawnEditor(options: EditorOptions): Promise<EditorResult>
135
143
 
136
144
  child.on("exit", (code) => {
137
145
  logger.debug("[editor] editor process exited", { code });
138
- logger.debug("[editor] calling renderer.currentRenderBuffer.clear()");
139
146
  renderer.currentRenderBuffer.clear();
140
- logger.debug("[editor] calling renderer.resume()");
141
- renderer.resume();
142
- logger.debug("[editor] queueMicrotask for requestRender");
147
+
148
+ if (!wasAlreadySuspended) {
149
+ logger.debug("[editor] calling renderer.resume()");
150
+ renderer.resume();
151
+ } else {
152
+ logger.debug("[editor] already suspended before spawn, skipping resume");
153
+ }
154
+
143
155
  queueMicrotask(() => {
144
- logger.debug("[editor] calling renderer.requestRender()");
145
156
  renderer.requestRender();
146
157
  });
147
158
 
@@ -80,12 +80,12 @@ export async function createPersonaViaEditor(options: NewPersonaEditorOptions):
80
80
  logger.debug("[persona-editor] YAML parse error in new persona", { error: errorMsg });
81
81
 
82
82
  const shouldReEdit = await new Promise<boolean>((resolve) => {
83
- ctx.showOverlay((hideOverlay) => (
83
+ ctx.showOverlay((hideOverlay, hideForEditor) => (
84
84
  <ConfirmOverlay
85
85
  message={`YAML parse error:\n${errorMsg}\n\nRe-edit?`}
86
86
  onConfirm={() => {
87
87
  logger.debug("[persona-editor] user confirmed re-edit (new)");
88
- hideOverlay();
88
+ hideForEditor();
89
89
  resolve(true);
90
90
  }}
91
91
  onCancel={() => {
@@ -94,12 +94,11 @@ export async function createPersonaViaEditor(options: NewPersonaEditorOptions):
94
94
  resolve(false);
95
95
  }}
96
96
  />
97
- ));
97
+ ), ctx.renderer);
98
98
  });
99
99
 
100
100
  if (shouldReEdit) {
101
101
  yamlContent = result.content;
102
- await new Promise(r => setTimeout(r, 50));
103
102
  continue;
104
103
  } else {
105
104
  ctx.showNotification("Creation cancelled", "info");
@@ -155,12 +154,12 @@ export async function openPersonaEditor(options: PersonaEditorOptions): Promise<
155
154
  logger.debug("[persona-editor] YAML parse error", { error: errorMsg });
156
155
 
157
156
  const shouldReEdit = await new Promise<boolean>((resolve) => {
158
- ctx.showOverlay((hideOverlay) => (
157
+ ctx.showOverlay((hideOverlay, hideForEditor) => (
159
158
  <ConfirmOverlay
160
159
  message={`YAML parse error:\n${errorMsg}\n\nRe-edit?`}
161
160
  onConfirm={() => {
162
161
  logger.debug("[persona-editor] user confirmed re-edit");
163
- hideOverlay();
162
+ hideForEditor();
164
163
  resolve(true);
165
164
  }}
166
165
  onCancel={() => {
@@ -169,12 +168,11 @@ export async function openPersonaEditor(options: PersonaEditorOptions): Promise<
169
168
  resolve(false);
170
169
  }}
171
170
  />
172
- ));
171
+ ), ctx.renderer);
173
172
  });
174
173
 
175
174
  if (shouldReEdit) {
176
175
  yamlContent = result.content;
177
- await new Promise(r => setTimeout(r, 50));
178
176
  continue;
179
177
  } else {
180
178
  ctx.showNotification("Changes discarded", "info");
@@ -66,11 +66,11 @@ export async function createProviderViaEditor(ctx: CommandContext): Promise<NewP
66
66
  logger.debug("[provider-editor] YAML parse error in new provider", { error: errorMsg });
67
67
 
68
68
  const shouldReEdit = await new Promise<boolean>((resolve) => {
69
- ctx.showOverlay((hideOverlay) => (
69
+ ctx.showOverlay((hideOverlay, hideForEditor) => (
70
70
  <ConfirmOverlay
71
71
  message={`YAML parse error:\n${errorMsg}\n\nRe-edit?`}
72
72
  onConfirm={() => {
73
- hideOverlay();
73
+ hideForEditor();
74
74
  resolve(true);
75
75
  }}
76
76
  onCancel={() => {
@@ -78,12 +78,11 @@ export async function createProviderViaEditor(ctx: CommandContext): Promise<NewP
78
78
  resolve(false);
79
79
  }}
80
80
  />
81
- ));
81
+ ), ctx.renderer);
82
82
  });
83
83
 
84
84
  if (shouldReEdit) {
85
85
  yamlContent = result.content;
86
- await new Promise(r => setTimeout(r, 50));
87
86
  continue;
88
87
  } else {
89
88
  ctx.showNotification("Creation cancelled", "info");
@@ -140,11 +139,11 @@ export async function openProviderEditor(account: ProviderAccount, ctx: CommandC
140
139
  logger.debug("[provider-editor] YAML parse error", { error: errorMsg });
141
140
 
142
141
  const shouldReEdit = await new Promise<boolean>((resolve) => {
143
- ctx.showOverlay((hideOverlay) => (
142
+ ctx.showOverlay((hideOverlay, hideForEditor) => (
144
143
  <ConfirmOverlay
145
144
  message={`YAML parse error:\n${errorMsg}\n\nRe-edit?`}
146
145
  onConfirm={() => {
147
- hideOverlay();
146
+ hideForEditor();
148
147
  resolve(true);
149
148
  }}
150
149
  onCancel={() => {
@@ -152,12 +151,11 @@ export async function openProviderEditor(account: ProviderAccount, ctx: CommandC
152
151
  resolve(false);
153
152
  }}
154
153
  />
155
- ));
154
+ ), ctx.renderer);
156
155
  });
157
156
 
158
157
  if (shouldReEdit) {
159
158
  yamlContent = result.content;
160
- await new Promise(r => setTimeout(r, 50));
161
159
  continue;
162
160
  } else {
163
161
  ctx.showNotification("Changes discarded", "info");
@@ -55,11 +55,11 @@ export async function openToolkitEditor(
55
55
  logger.debug("[toolkit-editor] YAML parse error", { error: errorMsg });
56
56
 
57
57
  const shouldReEdit = await new Promise<boolean>((resolve) => {
58
- ctx.showOverlay((hideOverlay) => (
58
+ ctx.showOverlay((hideOverlay, hideForEditor) => (
59
59
  <ConfirmOverlay
60
60
  message={`YAML parse error:\n${errorMsg}\n\nRe-edit?`}
61
61
  onConfirm={() => {
62
- hideOverlay();
62
+ hideForEditor();
63
63
  resolve(true);
64
64
  }}
65
65
  onCancel={() => {
@@ -67,12 +67,11 @@ export async function openToolkitEditor(
67
67
  resolve(false);
68
68
  }}
69
69
  />
70
- ));
70
+ ), ctx.renderer);
71
71
  });
72
72
 
73
73
  if (shouldReEdit) {
74
74
  yamlContent = result.content;
75
- await new Promise(r => setTimeout(r, 50));
76
75
  continue;
77
76
  } else {
78
77
  ctx.showNotification("Changes discarded", "info");