ei-tui 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/package.json +63 -0
  4. package/src/README.md +96 -0
  5. package/src/cli/README.md +47 -0
  6. package/src/cli/commands/facts.ts +25 -0
  7. package/src/cli/commands/people.ts +25 -0
  8. package/src/cli/commands/quotes.ts +19 -0
  9. package/src/cli/commands/topics.ts +25 -0
  10. package/src/cli/commands/traits.ts +25 -0
  11. package/src/cli/retrieval.ts +269 -0
  12. package/src/cli.ts +176 -0
  13. package/src/core/AGENTS.md +104 -0
  14. package/src/core/embedding-service.ts +241 -0
  15. package/src/core/handlers/index.ts +1057 -0
  16. package/src/core/index.ts +4 -0
  17. package/src/core/llm-client.ts +265 -0
  18. package/src/core/model-context-windows.ts +49 -0
  19. package/src/core/orchestrators/ceremony.ts +500 -0
  20. package/src/core/orchestrators/extraction-chunker.ts +138 -0
  21. package/src/core/orchestrators/human-extraction.ts +457 -0
  22. package/src/core/orchestrators/index.ts +28 -0
  23. package/src/core/orchestrators/persona-generation.ts +76 -0
  24. package/src/core/orchestrators/persona-topics.ts +117 -0
  25. package/src/core/personas/index.ts +5 -0
  26. package/src/core/personas/opencode-agent.ts +81 -0
  27. package/src/core/processor.ts +1413 -0
  28. package/src/core/queue-processor.ts +197 -0
  29. package/src/core/state/checkpoints.ts +68 -0
  30. package/src/core/state/human.ts +176 -0
  31. package/src/core/state/index.ts +5 -0
  32. package/src/core/state/personas.ts +217 -0
  33. package/src/core/state/queue.ts +144 -0
  34. package/src/core/state-manager.ts +347 -0
  35. package/src/core/types.ts +421 -0
  36. package/src/core/utils/decay.ts +33 -0
  37. package/src/index.ts +1 -0
  38. package/src/integrations/opencode/importer.ts +896 -0
  39. package/src/integrations/opencode/index.ts +16 -0
  40. package/src/integrations/opencode/json-reader.ts +304 -0
  41. package/src/integrations/opencode/reader-factory.ts +35 -0
  42. package/src/integrations/opencode/sqlite-reader.ts +189 -0
  43. package/src/integrations/opencode/types.ts +244 -0
  44. package/src/prompts/AGENTS.md +62 -0
  45. package/src/prompts/ceremony/description-check.ts +47 -0
  46. package/src/prompts/ceremony/expire.ts +30 -0
  47. package/src/prompts/ceremony/explore.ts +60 -0
  48. package/src/prompts/ceremony/index.ts +11 -0
  49. package/src/prompts/ceremony/types.ts +42 -0
  50. package/src/prompts/generation/descriptions.ts +91 -0
  51. package/src/prompts/generation/index.ts +15 -0
  52. package/src/prompts/generation/persona.ts +155 -0
  53. package/src/prompts/generation/seeds.ts +31 -0
  54. package/src/prompts/generation/types.ts +47 -0
  55. package/src/prompts/heartbeat/check.ts +179 -0
  56. package/src/prompts/heartbeat/ei.ts +208 -0
  57. package/src/prompts/heartbeat/index.ts +15 -0
  58. package/src/prompts/heartbeat/types.ts +70 -0
  59. package/src/prompts/human/fact-scan.ts +152 -0
  60. package/src/prompts/human/index.ts +32 -0
  61. package/src/prompts/human/item-match.ts +74 -0
  62. package/src/prompts/human/item-update.ts +322 -0
  63. package/src/prompts/human/person-scan.ts +115 -0
  64. package/src/prompts/human/topic-scan.ts +135 -0
  65. package/src/prompts/human/trait-scan.ts +115 -0
  66. package/src/prompts/human/types.ts +127 -0
  67. package/src/prompts/index.ts +90 -0
  68. package/src/prompts/message-utils.ts +39 -0
  69. package/src/prompts/persona/index.ts +16 -0
  70. package/src/prompts/persona/topics-match.ts +69 -0
  71. package/src/prompts/persona/topics-scan.ts +98 -0
  72. package/src/prompts/persona/topics-update.ts +157 -0
  73. package/src/prompts/persona/traits.ts +117 -0
  74. package/src/prompts/persona/types.ts +74 -0
  75. package/src/prompts/response/index.ts +147 -0
  76. package/src/prompts/response/sections.ts +355 -0
  77. package/src/prompts/response/types.ts +38 -0
  78. package/src/prompts/validation/ei.ts +93 -0
  79. package/src/prompts/validation/index.ts +6 -0
  80. package/src/prompts/validation/types.ts +22 -0
  81. package/src/storage/crypto.ts +96 -0
  82. package/src/storage/index.ts +5 -0
  83. package/src/storage/interface.ts +9 -0
  84. package/src/storage/local.ts +79 -0
  85. package/src/storage/merge.ts +69 -0
  86. package/src/storage/remote.ts +145 -0
  87. package/src/templates/welcome.ts +91 -0
  88. package/tui/README.md +62 -0
  89. package/tui/bunfig.toml +4 -0
  90. package/tui/src/app.tsx +55 -0
  91. package/tui/src/commands/archive.tsx +93 -0
  92. package/tui/src/commands/context.tsx +124 -0
  93. package/tui/src/commands/delete.tsx +71 -0
  94. package/tui/src/commands/details.tsx +41 -0
  95. package/tui/src/commands/editor.tsx +46 -0
  96. package/tui/src/commands/help.tsx +12 -0
  97. package/tui/src/commands/me.tsx +145 -0
  98. package/tui/src/commands/model.ts +47 -0
  99. package/tui/src/commands/new.ts +31 -0
  100. package/tui/src/commands/pause.ts +46 -0
  101. package/tui/src/commands/persona.tsx +58 -0
  102. package/tui/src/commands/provider.tsx +124 -0
  103. package/tui/src/commands/quit.ts +22 -0
  104. package/tui/src/commands/quotes.tsx +172 -0
  105. package/tui/src/commands/registry.test.ts +137 -0
  106. package/tui/src/commands/registry.ts +130 -0
  107. package/tui/src/commands/resume.ts +39 -0
  108. package/tui/src/commands/setsync.tsx +43 -0
  109. package/tui/src/commands/settings.tsx +83 -0
  110. package/tui/src/components/ConfirmOverlay.tsx +51 -0
  111. package/tui/src/components/ConflictOverlay.tsx +78 -0
  112. package/tui/src/components/HelpOverlay.tsx +69 -0
  113. package/tui/src/components/Layout.tsx +24 -0
  114. package/tui/src/components/MessageList.tsx +174 -0
  115. package/tui/src/components/PersonaListOverlay.tsx +186 -0
  116. package/tui/src/components/PromptInput.tsx +145 -0
  117. package/tui/src/components/ProviderListOverlay.tsx +208 -0
  118. package/tui/src/components/QuotesOverlay.tsx +157 -0
  119. package/tui/src/components/Sidebar.tsx +95 -0
  120. package/tui/src/components/StatusBar.tsx +77 -0
  121. package/tui/src/components/WelcomeOverlay.tsx +73 -0
  122. package/tui/src/context/ei.tsx +623 -0
  123. package/tui/src/context/keyboard.tsx +164 -0
  124. package/tui/src/context/overlay.tsx +53 -0
  125. package/tui/src/index.tsx +8 -0
  126. package/tui/src/storage/file.ts +185 -0
  127. package/tui/src/util/duration.ts +32 -0
  128. package/tui/src/util/editor.ts +188 -0
  129. package/tui/src/util/logger.ts +109 -0
  130. package/tui/src/util/persona-editor.tsx +181 -0
  131. package/tui/src/util/provider-editor.tsx +168 -0
  132. package/tui/src/util/syntax.ts +35 -0
  133. package/tui/src/util/yaml-serializers.ts +755 -0
@@ -0,0 +1,164 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ createSignal,
5
+ type ParentComponent,
6
+ type Accessor,
7
+ } from "solid-js";
8
+ import { useKeyboard, useRenderer } from "@opentui/solid";
9
+ import type { ScrollBoxRenderable, KeyEvent, TextareaRenderable, CliRenderer } from "@opentui/core";
10
+ import type { PersonaSummary } from "../../../src/core/types.js";
11
+ import { useEi } from "./ei";
12
+ import { logger } from "../util/logger";
13
+
14
+ export type Panel = "sidebar" | "messages" | "input";
15
+
16
+ interface KeyboardContextValue {
17
+ focusedPanel: Accessor<Panel>;
18
+ setFocusedPanel: (panel: Panel) => void;
19
+ registerMessageScroll: (scrollbox: ScrollBoxRenderable) => void;
20
+ registerTextarea: (textarea: TextareaRenderable) => void;
21
+ registerEditorHandler: (handler: () => Promise<void>) => void;
22
+ sidebarVisible: Accessor<boolean>;
23
+ toggleSidebar: () => void;
24
+ exitApp: () => Promise<void>;
25
+ renderer: CliRenderer;
26
+ }
27
+
28
+ const KeyboardContext = createContext<KeyboardContextValue>();
29
+
30
+ export const KeyboardProvider: ParentComponent = (props) => {
31
+ const [focusedPanel, setFocusedPanel] = createSignal<Panel>("input");
32
+ const [sidebarVisible, setSidebarVisible] = createSignal(true);
33
+ const renderer = useRenderer();
34
+ const { queueStatus, abortCurrentOperation, resumeQueue, personas, activePersonaId, selectPersona, saveAndExit, showNotification } = useEi();
35
+
36
+ let messageScrollRef: ScrollBoxRenderable | null = null;
37
+ let textareaRef: TextareaRenderable | null = null;
38
+ let editorHandler: (() => Promise<void>) | null = null;
39
+
40
+ const registerMessageScroll = (scrollbox: ScrollBoxRenderable) => {
41
+ messageScrollRef = scrollbox;
42
+ };
43
+
44
+ const registerTextarea = (textarea: TextareaRenderable) => {
45
+ textareaRef = textarea;
46
+ };
47
+
48
+ const registerEditorHandler = (handler: () => Promise<void>) => {
49
+ editorHandler = handler;
50
+ };
51
+
52
+ const toggleSidebar = () => setSidebarVisible(!sidebarVisible());
53
+
54
+ const exitApp = async () => {
55
+ const result = await saveAndExit();
56
+ if (!result.success) {
57
+ showNotification(`Sync failed: ${result.error}. Use /quit force to exit anyway.`, "error");
58
+ return;
59
+ }
60
+ renderer.setTerminalTitle("");
61
+ renderer.destroy();
62
+ process.exit(0);
63
+ };
64
+
65
+ useKeyboard((event: KeyEvent) => {
66
+ if (event.name === "tab") {
67
+ event.preventDefault();
68
+ if (textareaRef && textareaRef.plainText.length > 0) return;
69
+
70
+ const unarchived = personas().filter((p: PersonaSummary) => !p.is_archived);
71
+ if (unarchived.length <= 1) return;
72
+
73
+ const current = activePersonaId();
74
+ const currentIndex = unarchived.findIndex((p: PersonaSummary) => p.id === current);
75
+
76
+ let nextIndex: number;
77
+ if (event.shift) {
78
+ nextIndex = (currentIndex - 1 + unarchived.length) % unarchived.length;
79
+ } else {
80
+ nextIndex = (currentIndex + 1) % unarchived.length;
81
+ }
82
+ selectPersona(unarchived[nextIndex].id);
83
+ return;
84
+ }
85
+
86
+ if (event.name === "b" && event.ctrl) {
87
+ event.preventDefault();
88
+ toggleSidebar();
89
+ return;
90
+ }
91
+
92
+ if (event.name === "e" && event.ctrl) {
93
+ event.preventDefault();
94
+ if (editorHandler) {
95
+ void editorHandler();
96
+ }
97
+ return;
98
+ }
99
+
100
+ if (event.name === "c" && event.ctrl) {
101
+ event.preventDefault();
102
+
103
+ if (textareaRef && textareaRef.plainText.length > 0) {
104
+ logger.info("Ctrl+C pressed - clearing input");
105
+ textareaRef.clear();
106
+ } else {
107
+ void exitApp();
108
+ }
109
+ return;
110
+ }
111
+
112
+ if (event.name === "escape") {
113
+ event.preventDefault();
114
+ const status = queueStatus();
115
+
116
+ if (status.state === "busy") {
117
+ logger.info("Escape pressed - aborting current operation");
118
+ void abortCurrentOperation();
119
+ } else if (status.state === "paused") {
120
+ logger.info("Escape pressed - resuming queue");
121
+ void resumeQueue();
122
+ }
123
+ return;
124
+ }
125
+
126
+ if (!messageScrollRef) return;
127
+
128
+ const scrollAmount = messageScrollRef.height;
129
+
130
+ if (event.name === "pageup") {
131
+ event.preventDefault();
132
+ messageScrollRef.scrollBy(-scrollAmount);
133
+ } else if (event.name === "pagedown") {
134
+ event.preventDefault();
135
+ messageScrollRef.scrollBy(scrollAmount);
136
+ }
137
+ });
138
+
139
+ const value: KeyboardContextValue = {
140
+ focusedPanel,
141
+ setFocusedPanel,
142
+ registerMessageScroll,
143
+ registerTextarea,
144
+ registerEditorHandler,
145
+ sidebarVisible,
146
+ toggleSidebar,
147
+ exitApp,
148
+ renderer,
149
+ };
150
+
151
+ return (
152
+ <KeyboardContext.Provider value={value}>
153
+ {props.children}
154
+ </KeyboardContext.Provider>
155
+ );
156
+ };
157
+
158
+ export const useKeyboardNav = () => {
159
+ const ctx = useContext(KeyboardContext);
160
+ if (!ctx) {
161
+ throw new Error("useKeyboardNav must be used within KeyboardProvider");
162
+ }
163
+ return ctx;
164
+ };
@@ -0,0 +1,53 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ createSignal,
5
+ type ParentComponent,
6
+ type JSX,
7
+ type Accessor,
8
+ } from "solid-js";
9
+ import { logger } from "../util/logger";
10
+
11
+ export type OverlayRenderer = (hideOverlay: () => void) => JSX.Element;
12
+
13
+ interface OverlayContextValue {
14
+ overlayRenderer: Accessor<OverlayRenderer | null>;
15
+ showOverlay: (renderer: OverlayRenderer) => void;
16
+ hideOverlay: () => void;
17
+ }
18
+
19
+ const OverlayContext = createContext<OverlayContextValue>();
20
+
21
+ export const OverlayProvider: ParentComponent = (props) => {
22
+ const [overlayRenderer, setOverlayRenderer] = createSignal<OverlayRenderer | null>(null);
23
+
24
+ const showOverlay = (renderer: OverlayRenderer) => {
25
+ logger.debug("[overlay] showOverlay called");
26
+ setOverlayRenderer(() => renderer);
27
+ };
28
+
29
+ const hideOverlay = () => {
30
+ logger.debug("[overlay] hideOverlay called");
31
+ setOverlayRenderer(null);
32
+ };
33
+
34
+ const value: OverlayContextValue = {
35
+ overlayRenderer,
36
+ showOverlay,
37
+ hideOverlay,
38
+ };
39
+
40
+ return (
41
+ <OverlayContext.Provider value={value}>
42
+ {props.children}
43
+ </OverlayContext.Provider>
44
+ );
45
+ };
46
+
47
+ export const useOverlay = () => {
48
+ const ctx = useContext(OverlayContext);
49
+ if (!ctx) {
50
+ throw new Error("useOverlay must be used within OverlayProvider");
51
+ }
52
+ return ctx;
53
+ };
@@ -0,0 +1,8 @@
1
+ import { render } from "@opentui/solid";
2
+ import { App } from "./app";
3
+
4
+ render(App, {
5
+ exitOnCtrlC: false,
6
+ targetFps: 30,
7
+ useAlternateScreen: true,
8
+ });
@@ -0,0 +1,185 @@
1
+ import type { StorageState } from "../../../src/core/types";
2
+ import type { Storage } from "../../../src/storage/interface";
3
+ import { join } from "path";
4
+ import { mkdir, rename, unlink } from "fs/promises";
5
+
6
+ const STATE_FILE = "state.json";
7
+ const BACKUP_FILE = "state.backup.json";
8
+ const LOCK_TIMEOUT_MS = 5000;
9
+ const LOCK_RETRY_DELAY_MS = 50;
10
+
11
+ export class FileStorage implements Storage {
12
+ private dataPath: string;
13
+
14
+ constructor(dataPath?: string) {
15
+ if (dataPath) {
16
+ this.dataPath = dataPath;
17
+ } else if (process.env.EI_DATA_PATH) {
18
+ this.dataPath = process.env.EI_DATA_PATH;
19
+ } else {
20
+ const xdgData = process.env.XDG_DATA_HOME || join(process.env.HOME || "~", ".local", "share");
21
+ this.dataPath = join(xdgData, "ei");
22
+ }
23
+ }
24
+
25
+ async isAvailable(): Promise<boolean> {
26
+ try {
27
+ await this.ensureDataDir();
28
+ const testFile = join(this.dataPath, "__ei_storage_test__");
29
+ await Bun.write(testFile, "1");
30
+ await Bun.write(testFile, "");
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ async save(state: StorageState): Promise<void> {
38
+ await this.ensureDataDir();
39
+ const filePath = join(this.dataPath, STATE_FILE);
40
+ state.timestamp = new Date().toISOString();
41
+
42
+ await this.withLock(filePath, async () => {
43
+ try {
44
+ await this.atomicWrite(filePath, JSON.stringify(state, null, 2));
45
+ } catch (e) {
46
+ if (this.isQuotaError(e)) {
47
+ throw new Error("STORAGE_SAVE_FAILED: Disk quota exceeded");
48
+ }
49
+ throw e;
50
+ }
51
+ });
52
+ }
53
+
54
+ async load(): Promise<StorageState | null> {
55
+ const filePath = join(this.dataPath, STATE_FILE);
56
+ const file = Bun.file(filePath);
57
+
58
+ if (await file.exists()) {
59
+ try {
60
+ const text = await file.text();
61
+ if (text) {
62
+ return JSON.parse(text) as StorageState;
63
+ }
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ return null;
70
+ }
71
+
72
+ async moveToBackup(): Promise<void> {
73
+ const statePath = join(this.dataPath, STATE_FILE);
74
+ const backupPath = join(this.dataPath, BACKUP_FILE);
75
+ const stateFile = Bun.file(statePath);
76
+
77
+ if (await stateFile.exists()) {
78
+ await rename(statePath, backupPath);
79
+ }
80
+ }
81
+
82
+
83
+ /**
84
+ * Read backup state without removing it.
85
+ * Used to peek sync credentials from a previous session's backup.
86
+ */
87
+ async loadBackup(): Promise<StorageState | null> {
88
+ const backupPath = join(this.dataPath, BACKUP_FILE);
89
+ const backupFile = Bun.file(backupPath);
90
+
91
+ if (await backupFile.exists()) {
92
+ try {
93
+ const text = await backupFile.text();
94
+ if (text) {
95
+ return JSON.parse(text) as StorageState;
96
+ }
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ return null;
103
+ }
104
+
105
+ private async ensureDataDir(): Promise<void> {
106
+ try {
107
+ await mkdir(this.dataPath, { recursive: true });
108
+ } catch {
109
+ return;
110
+ }
111
+ }
112
+
113
+ private isQuotaError(e: unknown): boolean {
114
+ return (
115
+ e instanceof Error &&
116
+ (e.message.includes("ENOSPC") || e.message.includes("quota"))
117
+ );
118
+ }
119
+
120
+ private async atomicWrite(filePath: string, content: string): Promise<void> {
121
+ const tempPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}`;
122
+ try {
123
+ await Bun.write(tempPath, content);
124
+ await rename(tempPath, filePath);
125
+ } catch (e) {
126
+ try {
127
+ await unlink(tempPath);
128
+ } catch {}
129
+ throw e;
130
+ }
131
+ }
132
+
133
+ private getLockPath(filePath: string): string {
134
+ return `${filePath}.lock`;
135
+ }
136
+
137
+ private async acquireLock(filePath: string): Promise<boolean> {
138
+ const lockPath = this.getLockPath(filePath);
139
+ const startTime = Date.now();
140
+
141
+ while (Date.now() - startTime < LOCK_TIMEOUT_MS) {
142
+ const lockFile = Bun.file(lockPath);
143
+ if (await lockFile.exists()) {
144
+ const lockContent = await lockFile.text();
145
+ const lockTime = parseInt(lockContent, 10);
146
+ if (!isNaN(lockTime) && Date.now() - lockTime > LOCK_TIMEOUT_MS) {
147
+ try {
148
+ await unlink(lockPath);
149
+ } catch {}
150
+ } else {
151
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_DELAY_MS));
152
+ continue;
153
+ }
154
+ }
155
+
156
+ try {
157
+ await Bun.write(lockPath, Date.now().toString());
158
+ return true;
159
+ } catch {
160
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_DELAY_MS));
161
+ }
162
+ }
163
+
164
+ return false;
165
+ }
166
+
167
+ private async releaseLock(filePath: string): Promise<void> {
168
+ const lockPath = this.getLockPath(filePath);
169
+ try {
170
+ await unlink(lockPath);
171
+ } catch {}
172
+ }
173
+
174
+ private async withLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
175
+ const acquired = await this.acquireLock(filePath);
176
+ if (!acquired) {
177
+ throw new Error("STORAGE_LOCK_TIMEOUT: Could not acquire file lock");
178
+ }
179
+ try {
180
+ return await fn();
181
+ } finally {
182
+ await this.releaseLock(filePath);
183
+ }
184
+ }
185
+ }
@@ -0,0 +1,32 @@
1
+ const MINUTE = 60 * 1000;
2
+ const HOUR = 60 * MINUTE;
3
+ const DAY = 24 * HOUR;
4
+ const WEEK = 7 * DAY;
5
+
6
+ const MULTIPLIERS: Record<string, number> = {
7
+ m: MINUTE,
8
+ min: MINUTE,
9
+ h: HOUR,
10
+ hour: HOUR,
11
+ d: DAY,
12
+ day: DAY,
13
+ w: WEEK,
14
+ week: WEEK,
15
+ };
16
+
17
+ export function parseDuration(input: string): number | null {
18
+ const match = input.match(/^(\d+)(m|min|h|hour|d|day|w|week)s?$/i);
19
+ if (!match) return null;
20
+
21
+ const value = parseInt(match[1], 10);
22
+ const unit = match[2].toLowerCase();
23
+
24
+ return value * (MULTIPLIERS[unit] || 0);
25
+ }
26
+
27
+ export function formatDuration(ms: number): string {
28
+ if (ms >= WEEK) return `${Math.floor(ms / WEEK)}w`;
29
+ if (ms >= DAY) return `${Math.floor(ms / DAY)}d`;
30
+ if (ms >= HOUR) return `${Math.floor(ms / HOUR)}h`;
31
+ return `${Math.floor(ms / MINUTE)}m`;
32
+ }
@@ -0,0 +1,188 @@
1
+ import { spawn } from "child_process";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import type { CliRenderer } from "@opentui/core";
6
+ import { logger } from "./logger";
7
+
8
+ export interface EditorOptions {
9
+ initialContent: string;
10
+ filename: string;
11
+ renderer: CliRenderer;
12
+ }
13
+
14
+ export interface EditorRawOptions {
15
+ initialContent: string;
16
+ filename: string;
17
+ }
18
+
19
+ export interface EditorResult {
20
+ success: boolean;
21
+ content: string | null;
22
+ aborted: boolean;
23
+ }
24
+
25
+ export async function spawnEditorRaw(options: EditorRawOptions): Promise<EditorResult> {
26
+ const { initialContent, filename } = options;
27
+ const editor = process.env.EDITOR || process.env.VISUAL || "vi";
28
+ const tmpDir = os.tmpdir();
29
+ const safeName = filename.replace(/\s+/g, "-");
30
+ const tmpFile = path.join(tmpDir, `ei-${Date.now()}-${safeName}`);
31
+
32
+ logger.debug("[editor] spawnEditorRaw called", { filename, editor });
33
+
34
+ fs.writeFileSync(tmpFile, initialContent, "utf-8");
35
+ const originalContent = initialContent;
36
+
37
+ return new Promise((resolve) => {
38
+ logger.debug("[editor] spawning editor process (raw)");
39
+ const child = spawn(editor, [tmpFile], {
40
+ stdio: "inherit",
41
+ shell: true,
42
+ });
43
+
44
+ child.on("error", () => {
45
+ logger.error("[editor] editor process error (raw)");
46
+ try { fs.unlinkSync(tmpFile); } catch {}
47
+ resolve({
48
+ success: false,
49
+ content: null,
50
+ aborted: false,
51
+ });
52
+ });
53
+
54
+ child.on("exit", (code) => {
55
+ logger.debug("[editor] editor process exited (raw)", { code });
56
+
57
+ if (code !== 0) {
58
+ try { fs.unlinkSync(tmpFile); } catch {}
59
+ resolve({
60
+ success: false,
61
+ content: null,
62
+ aborted: true,
63
+ });
64
+ return;
65
+ }
66
+
67
+ let editedContent: string;
68
+ try {
69
+ editedContent = fs.readFileSync(tmpFile, "utf-8");
70
+ } catch {
71
+ resolve({
72
+ success: false,
73
+ content: null,
74
+ aborted: false,
75
+ });
76
+ return;
77
+ } finally {
78
+ try { fs.unlinkSync(tmpFile); } catch {}
79
+ }
80
+
81
+ if (editedContent === originalContent) {
82
+ resolve({
83
+ success: true,
84
+ content: null,
85
+ aborted: false,
86
+ });
87
+ return;
88
+ }
89
+
90
+ resolve({
91
+ success: true,
92
+ content: editedContent,
93
+ aborted: false,
94
+ });
95
+ });
96
+ });
97
+ }
98
+
99
+ export async function spawnEditor(options: EditorOptions): Promise<EditorResult> {
100
+ const { initialContent, filename, renderer } = options;
101
+ const editor = process.env.EDITOR || process.env.VISUAL || "vi";
102
+ const tmpDir = os.tmpdir();
103
+ const safeName = filename.replace(/\s+/g, "-");
104
+ const tmpFile = path.join(tmpDir, `ei-${Date.now()}-${safeName}`);
105
+
106
+ logger.debug("[editor] spawnEditor called", { filename, editor });
107
+
108
+ fs.writeFileSync(tmpFile, initialContent, "utf-8");
109
+ const originalContent = initialContent;
110
+
111
+ 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();
116
+
117
+ logger.debug("[editor] spawning editor process");
118
+ const child = spawn(editor, [tmpFile], {
119
+ stdio: "inherit",
120
+ shell: true,
121
+ });
122
+
123
+ child.on("error", () => {
124
+ logger.error("[editor] editor process error");
125
+ renderer.currentRenderBuffer.clear();
126
+ renderer.resume();
127
+ renderer.requestRender();
128
+ try { fs.unlinkSync(tmpFile); } catch {}
129
+ resolve({
130
+ success: false,
131
+ content: null,
132
+ aborted: false,
133
+ });
134
+ });
135
+
136
+ child.on("exit", (code) => {
137
+ logger.debug("[editor] editor process exited", { code });
138
+ logger.debug("[editor] calling renderer.currentRenderBuffer.clear()");
139
+ renderer.currentRenderBuffer.clear();
140
+ logger.debug("[editor] calling renderer.resume()");
141
+ renderer.resume();
142
+ logger.debug("[editor] queueMicrotask for requestRender");
143
+ queueMicrotask(() => {
144
+ logger.debug("[editor] calling renderer.requestRender()");
145
+ renderer.requestRender();
146
+ });
147
+
148
+ if (code !== 0) {
149
+ try { fs.unlinkSync(tmpFile); } catch {}
150
+ resolve({
151
+ success: false,
152
+ content: null,
153
+ aborted: true,
154
+ });
155
+ return;
156
+ }
157
+
158
+ let editedContent: string;
159
+ try {
160
+ editedContent = fs.readFileSync(tmpFile, "utf-8");
161
+ } catch {
162
+ resolve({
163
+ success: false,
164
+ content: null,
165
+ aborted: false,
166
+ });
167
+ return;
168
+ } finally {
169
+ try { fs.unlinkSync(tmpFile); } catch {}
170
+ }
171
+
172
+ if (editedContent === originalContent) {
173
+ resolve({
174
+ success: true,
175
+ content: null,
176
+ aborted: false,
177
+ });
178
+ return;
179
+ }
180
+
181
+ resolve({
182
+ success: true,
183
+ content: editedContent,
184
+ aborted: false,
185
+ });
186
+ });
187
+ });
188
+ }