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,124 @@
1
+ import type { Command } from "./registry";
2
+
3
+ import { ProviderListOverlay, type ProviderListItem } from "../components/ProviderListOverlay";
4
+ import { createProviderViaEditor, openProviderEditor } from "../util/provider-editor.js";
5
+
6
+ /**
7
+ * Build provider list from user's ProviderAccount settings.
8
+ */
9
+ async function getProviderList(ctx: Parameters<Command["execute"]>[1]): Promise<ProviderListItem[]> {
10
+ const human = await ctx.ei.getHuman();
11
+ const accounts = human.settings?.accounts?.filter(a => a.type === "llm") ?? [];
12
+
13
+ return accounts.map(acc => ({
14
+ id: acc.id,
15
+ displayName: acc.name,
16
+ key: acc.name,
17
+ defaultModel: acc.default_model,
18
+ enabled: acc.enabled ?? true,
19
+ }));
20
+ }
21
+
22
+ /**
23
+ * Extract the provider key from a persona's current model spec.
24
+ * "Local LLM:llama-3" -> "Local LLM"
25
+ * "Local LLM" -> "Local LLM"
26
+ * undefined -> null
27
+ */
28
+ function getActiveProviderKey(model?: string): string | null {
29
+ if (!model) return null;
30
+ return model.includes(":") ? model.split(":")[0] : model;
31
+ }
32
+
33
+ /**
34
+ * Set a provider (with optional default model) on the active persona.
35
+ */
36
+ async function setProviderOnPersona(
37
+ providerKey: string,
38
+ defaultModel: string | undefined,
39
+ ctx: Parameters<Command["execute"]>[1]
40
+ ): Promise<void> {
41
+ const personaId = ctx.ei.activePersonaId();
42
+ if (!personaId) {
43
+ ctx.showNotification("No persona selected", "error");
44
+ return;
45
+ }
46
+
47
+ const modelSpec = defaultModel ? `${providerKey}:${defaultModel}` : providerKey;
48
+ await ctx.ei.updatePersona(personaId, { model: modelSpec });
49
+ ctx.showNotification(`Provider set to ${modelSpec}`, "info");
50
+ }
51
+
52
+ /**
53
+ * Find a matching provider by name (case-insensitive).
54
+ */
55
+ function findProvider(providers: ProviderListItem[], name: string): ProviderListItem | undefined {
56
+ const lower = name.toLowerCase();
57
+ return providers.find(p =>
58
+ p.key.toLowerCase() === lower || p.displayName.toLowerCase() === lower
59
+ );
60
+ }
61
+
62
+ export const providerCommand: Command = {
63
+ name: "provider",
64
+ aliases: ["providers"],
65
+ description: "Manage LLM providers",
66
+ usage: "/provider [name] | /provider new",
67
+
68
+ async execute(args, ctx) {
69
+ const personaId = ctx.ei.activePersonaId();
70
+ const persona = personaId ? await ctx.ei.getPersona(personaId) : null;
71
+ const activeKey = getActiveProviderKey(persona?.model);
72
+ const providers = await getProviderList(ctx);
73
+
74
+ // No args -> show overlay
75
+ if (args.length === 0) {
76
+ if (providers.length === 0) {
77
+ ctx.showNotification("No providers configured. Use /provider new to create one.", "info");
78
+ return;
79
+ }
80
+ ctx.showOverlay((hideOverlay) => (
81
+ <ProviderListOverlay
82
+ providers={providers}
83
+ activeProviderKey={activeKey}
84
+ onSelect={async (provider) => {
85
+ hideOverlay();
86
+ await setProviderOnPersona(provider.key, provider.defaultModel, ctx);
87
+ }}
88
+ onEdit={async (provider) => {
89
+ hideOverlay();
90
+ await new Promise(r => setTimeout(r, 50));
91
+ const human = await ctx.ei.getHuman();
92
+ const account = human.settings?.accounts?.find(a => a.id === provider.id);
93
+ if (account) {
94
+ await openProviderEditor(account, ctx);
95
+ }
96
+ }}
97
+ onNew={async () => {
98
+ hideOverlay();
99
+ await new Promise(r => setTimeout(r, 50));
100
+ await createProviderViaEditor(ctx);
101
+ }}
102
+ onDismiss={hideOverlay}
103
+ />
104
+ ));
105
+ return;
106
+ }
107
+
108
+ // /provider new
109
+ if (args[0].toLowerCase() === "new") {
110
+ await createProviderViaEditor(ctx);
111
+ return;
112
+ }
113
+
114
+ // /provider <name> -> set that provider directly
115
+ const name = args.join(" ");
116
+ const match = findProvider(providers, name);
117
+
118
+ if (match) {
119
+ await setProviderOnPersona(match.key, match.defaultModel, ctx);
120
+ } else {
121
+ ctx.showNotification(`No provider named "${name}". Run \`/provider new\` to create.`, "warn");
122
+ }
123
+ }
124
+ };
@@ -0,0 +1,22 @@
1
+ import type { Command } from "./registry";
2
+
3
+ export const quitCommand: Command = {
4
+ name: "quit",
5
+ aliases: ["q"],
6
+ description: "Exit the application",
7
+ usage: "/quit or /q (add ! or 'force' to force quit without syncing)",
8
+ execute: async (args, ctx) => {
9
+ const forceQuit = args.includes("--force") || args.includes("force");
10
+
11
+ if (forceQuit) {
12
+ ctx.showNotification("Force quitting...", "info");
13
+ await ctx.stopProcessor();
14
+ ctx.renderer.setTerminalTitle("");
15
+ ctx.renderer.destroy();
16
+ process.exit(0);
17
+ }
18
+
19
+ ctx.showNotification("Saving and syncing...", "info");
20
+ await ctx.exitApp();
21
+ },
22
+ };
@@ -0,0 +1,172 @@
1
+ import type { Command } from "./registry.js";
2
+ import { spawnEditor } from "../util/editor.js";
3
+ import { quotesToYAML, quotesFromYAML } from "../util/yaml-serializers.js";
4
+ import { logger } from "../util/logger.js";
5
+ import { ConfirmOverlay } from "../components/ConfirmOverlay.js";
6
+ import { QuotesOverlay } from "../components/QuotesOverlay.js";
7
+ import type { Quote } from "../../../src/core/types.js";
8
+
9
+ async function openQuotesInEditor(
10
+ ctx: Parameters<Command["execute"]>[1],
11
+ quotes: Quote[],
12
+ label: string
13
+ ): Promise<void> {
14
+ if (quotes.length === 0) {
15
+ ctx.showNotification(`No ${label} found`, "info");
16
+ return;
17
+ }
18
+
19
+ let yamlContent = quotesToYAML(quotes);
20
+ let editorIteration = 0;
21
+
22
+ while (true) {
23
+ editorIteration++;
24
+ logger.debug("[quotes] starting editor iteration", { iteration: editorIteration });
25
+
26
+ const result = await spawnEditor({
27
+ initialContent: yamlContent,
28
+ filename: "quotes.yaml",
29
+ renderer: ctx.renderer,
30
+ });
31
+
32
+ logger.debug("[quotes] editor returned", {
33
+ iteration: editorIteration,
34
+ aborted: result.aborted,
35
+ success: result.success,
36
+ hasContent: result.content !== null,
37
+ });
38
+
39
+ if (result.aborted) {
40
+ ctx.showNotification("Editor cancelled", "info");
41
+ return;
42
+ }
43
+
44
+ if (!result.success) {
45
+ ctx.showNotification("Editor failed to open", "error");
46
+ return;
47
+ }
48
+
49
+ if (result.content === null) {
50
+ ctx.showNotification("No changes made", "info");
51
+ return;
52
+ }
53
+
54
+ try {
55
+ const parsed = quotesFromYAML(result.content);
56
+
57
+ for (const id of parsed.deletedQuoteIds) {
58
+ await ctx.ei.removeQuote(id);
59
+ }
60
+
61
+ for (const quote of parsed.quotes) {
62
+ await ctx.ei.updateQuote(quote.id, quote);
63
+ }
64
+
65
+ const deleteCount = parsed.deletedQuoteIds.length;
66
+ const updateCount = parsed.quotes.length;
67
+
68
+ ctx.showNotification(`Updated ${updateCount} quotes, deleted ${deleteCount}`, "info");
69
+ return;
70
+ } catch (parseError) {
71
+ const errorMsg = parseError instanceof Error ? parseError.message : String(parseError);
72
+ logger.debug("[quotes] YAML parse error, prompting for re-edit", {
73
+ iteration: editorIteration,
74
+ error: errorMsg,
75
+ });
76
+
77
+ const shouldReEdit = await new Promise<boolean>((resolve) => {
78
+ ctx.showOverlay((hideOverlay) => (
79
+ <ConfirmOverlay
80
+ message={`YAML parse error:\n${errorMsg}\n\nRe-edit?`}
81
+ onConfirm={() => {
82
+ logger.debug("[quotes] user confirmed re-edit");
83
+ hideOverlay();
84
+ resolve(true);
85
+ }}
86
+ onCancel={() => {
87
+ logger.debug("[quotes] user cancelled re-edit");
88
+ hideOverlay();
89
+ resolve(false);
90
+ }}
91
+ />
92
+ ));
93
+ });
94
+
95
+ logger.debug("[quotes] shouldReEdit", { shouldReEdit, iteration: editorIteration });
96
+
97
+ if (shouldReEdit) {
98
+ yamlContent = result.content;
99
+ logger.debug("[quotes] continuing to next iteration");
100
+ await new Promise((r) => setTimeout(r, 50));
101
+ continue;
102
+ } else {
103
+ ctx.showNotification("Changes discarded", "info");
104
+ return;
105
+ }
106
+ }
107
+ }
108
+ }
109
+
110
+ export const quotesCommand: Command = {
111
+ name: "quotes",
112
+ aliases: ["quote"],
113
+ description: "Manage quotes",
114
+ usage: "/quotes [N | search \"term\" | me | <persona>]",
115
+
116
+ async execute(args, ctx) {
117
+ if (args.length === 0) {
118
+ const all = await ctx.ei.getQuotes();
119
+ await openQuotesInEditor(ctx, all, "quotes");
120
+ return;
121
+ }
122
+
123
+ if (args[0] === "search" && args.length > 1) {
124
+ const term = args.slice(1).join(" ").replace(/^"|"$/g, "");
125
+ const results = await ctx.ei.searchHumanData(term, { types: ["quote"], limit: 20 });
126
+ await openQuotesInEditor(ctx, results.quotes, `quotes matching "${term}"`);
127
+ return;
128
+ }
129
+
130
+ if (args[0] === "me") {
131
+ const humanQuotes = await ctx.ei.getQuotes({ speaker: "human" });
132
+ await openQuotesInEditor(ctx, humanQuotes, "your quotes");
133
+ return;
134
+ }
135
+
136
+ if (/^\d+$/.test(args[0])) {
137
+ const index = parseInt(args[0], 10);
138
+ const messages = ctx.ei.messages();
139
+
140
+ if (index < 1 || index > messages.length) {
141
+ ctx.showNotification(`No message at index [${index}]`, "error");
142
+ return;
143
+ }
144
+
145
+ const targetMessage = messages[index - 1];
146
+ const allQuotes = await ctx.ei.getQuotes();
147
+ const messageQuotes = allQuotes.filter(q => q.message_id === targetMessage.id);
148
+
149
+ ctx.showOverlay((hide) => (
150
+ <QuotesOverlay
151
+ quotes={messageQuotes}
152
+ messageIndex={index}
153
+ onClose={hide}
154
+ onEdit={async () => {
155
+ hide();
156
+ await new Promise((r) => setTimeout(r, 50));
157
+ await openQuotesInEditor(ctx, messageQuotes, `quotes from message [${index}]`);
158
+ }}
159
+ onDelete={async (quoteId) => {
160
+ await ctx.ei.removeQuote(quoteId);
161
+ ctx.showNotification("Quote deleted", "info");
162
+ }}
163
+ />
164
+ ));
165
+ return;
166
+ }
167
+
168
+ const speaker = args.join(" ");
169
+ const speakerQuotes = await ctx.ei.getQuotes({ speaker });
170
+ await openQuotesInEditor(ctx, speakerQuotes, `${speaker}'s quotes`);
171
+ },
172
+ };
@@ -0,0 +1,137 @@
1
+ import { test, expect, describe, beforeEach, mock } from "bun:test";
2
+ import { parseCommandLine, registerCommand, parseAndExecute, getAllCommands, type Command, type CommandContext } from "./registry";
3
+
4
+ describe("parseCommandLine", () => {
5
+ test("parses simple command", () => {
6
+ expect(parseCommandLine("quit")).toEqual(["quit"]);
7
+ });
8
+
9
+ test("parses command with arguments", () => {
10
+ expect(parseCommandLine("persona switch Ei")).toEqual(["persona", "switch", "Ei"]);
11
+ });
12
+
13
+ test("parses quoted arguments with spaces", () => {
14
+ expect(parseCommandLine('message "hello world"')).toEqual(["message", "hello world"]);
15
+ });
16
+
17
+ test("parses single-quoted arguments", () => {
18
+ expect(parseCommandLine("message 'hello world'")).toEqual(["message", "hello world"]);
19
+ });
20
+
21
+ test("handles empty input", () => {
22
+ expect(parseCommandLine("")).toEqual([]);
23
+ });
24
+
25
+ test("handles multiple spaces between args", () => {
26
+ expect(parseCommandLine("cmd arg1 arg2")).toEqual(["cmd", "arg1", "arg2"]);
27
+ });
28
+
29
+ test("handles mixed quoted and unquoted", () => {
30
+ expect(parseCommandLine('cmd arg1 "arg 2" arg3')).toEqual(["cmd", "arg1", "arg 2", "arg3"]);
31
+ });
32
+ });
33
+
34
+ describe("registerCommand and getAllCommands", () => {
35
+ test("registers command by name", () => {
36
+ const testCmd: Command = {
37
+ name: "testcmd",
38
+ aliases: [],
39
+ description: "Test command",
40
+ usage: "/testcmd",
41
+ execute: async () => {},
42
+ };
43
+
44
+ registerCommand(testCmd);
45
+ const commands = getAllCommands();
46
+ expect(commands.some(c => c.name === "testcmd")).toBe(true);
47
+ });
48
+
49
+ test("getAllCommands returns no duplicates from aliases", () => {
50
+ const cmdWithAlias: Command = {
51
+ name: "aliased",
52
+ aliases: ["a", "al"],
53
+ description: "Command with aliases",
54
+ usage: "/aliased or /a or /al",
55
+ execute: async () => {},
56
+ };
57
+
58
+ registerCommand(cmdWithAlias);
59
+ const commands = getAllCommands();
60
+ const aliasedCommands = commands.filter(c => c.name === "aliased");
61
+ expect(aliasedCommands.length).toBe(1);
62
+ });
63
+ });
64
+
65
+ describe("parseAndExecute", () => {
66
+ const mockContext: CommandContext = {
67
+ showOverlay: mock(() => {}),
68
+ hideOverlay: mock(() => {}),
69
+ showNotification: mock(() => {}),
70
+ exitApp: mock(() => {}),
71
+ stopProcessor: mock(async () => {}),
72
+ ei: {
73
+ personas: () => [],
74
+ activePersona: () => null,
75
+ messages: () => [],
76
+ queueStatus: () => ({ state: "idle" as const, pending_count: 0 }),
77
+ notification: () => null,
78
+ selectPersona: () => {},
79
+ sendMessage: async () => {},
80
+ refreshPersonas: async () => {},
81
+ refreshMessages: async () => {},
82
+ abortCurrentOperation: async () => {},
83
+ resumeQueue: async () => {},
84
+ stopProcessor: async () => {},
85
+ showNotification: () => {},
86
+ createPersona: async () => {},
87
+ archivePersona: async () => {},
88
+ unarchivePersona: async () => {},
89
+ setContextBoundary: async () => {},
90
+ updatePersona: async () => {},
91
+ },
92
+ };
93
+
94
+ beforeEach(() => {
95
+ (mockContext.showNotification as ReturnType<typeof mock>).mockReset();
96
+ (mockContext.exitApp as ReturnType<typeof mock>).mockReset();
97
+ });
98
+
99
+ test("returns false for non-command input", async () => {
100
+ const result = await parseAndExecute("hello world", mockContext);
101
+ expect(result).toBe(false);
102
+ });
103
+
104
+ test("returns true for command input", async () => {
105
+ const result = await parseAndExecute("/help", mockContext);
106
+ expect(result).toBe(true);
107
+ });
108
+
109
+ test("shows error for unknown command", async () => {
110
+ await parseAndExecute("/unknownxyz", mockContext);
111
+ expect(mockContext.showNotification).toHaveBeenCalledWith(
112
+ "Unknown command: /unknownxyz",
113
+ "error"
114
+ );
115
+ });
116
+
117
+ test("handles force suffix (!)", async () => {
118
+ const forceCmd: Command = {
119
+ name: "forceable",
120
+ aliases: [],
121
+ description: "Forceable command",
122
+ usage: "/forceable",
123
+ execute: mock(async (args) => {
124
+ expect(args).toContain("--force");
125
+ }),
126
+ };
127
+
128
+ registerCommand(forceCmd);
129
+ await parseAndExecute("/forceable!", mockContext);
130
+ expect(forceCmd.execute).toHaveBeenCalled();
131
+ });
132
+
133
+ test("returns true for empty command (just /)", async () => {
134
+ const result = await parseAndExecute("/", mockContext);
135
+ expect(result).toBe(true);
136
+ });
137
+ });
@@ -0,0 +1,130 @@
1
+ import type { OverlayRenderer } from "../context/overlay";
2
+ import type { EiContextValue } from "../context/ei";
3
+ import type { CliRenderer } from "@opentui/core";
4
+
5
+ export interface Command {
6
+ name: string;
7
+ aliases: string[];
8
+ description: string;
9
+ usage: string;
10
+ execute: (args: string[], context: CommandContext) => Promise<void>;
11
+ }
12
+
13
+ export interface CommandContext {
14
+ showOverlay: (renderer: OverlayRenderer) => void;
15
+ hideOverlay: () => void;
16
+ showNotification: (msg: string, level: "error" | "warn" | "info") => void;
17
+ exitApp: () => Promise<void>;
18
+ stopProcessor: () => Promise<void>;
19
+ ei: EiContextValue;
20
+ renderer: CliRenderer;
21
+ setInputText: (text: string) => void;
22
+ getInputText: () => string;
23
+ }
24
+
25
+ const commands = new Map<string, Command>();
26
+ const commandsByName = new Map<string, Command>();
27
+
28
+ /**
29
+ * Parse command line input, respecting quoted strings
30
+ */
31
+ export function parseCommandLine(input: string): string[] {
32
+ const args: string[] = [];
33
+ let current = "";
34
+ let inQuotes = false;
35
+ let quoteChar = "";
36
+
37
+ for (let i = 0; i < input.length; i++) {
38
+ const char = input[i];
39
+
40
+ if ((char === '"' || char === "'") && !inQuotes) {
41
+ inQuotes = true;
42
+ quoteChar = char;
43
+ } else if (char === quoteChar && inQuotes) {
44
+ inQuotes = false;
45
+ quoteChar = "";
46
+ } else if (char === " " && !inQuotes) {
47
+ if (current) {
48
+ args.push(current);
49
+ current = "";
50
+ }
51
+ } else {
52
+ current += char;
53
+ }
54
+ }
55
+
56
+ if (current) {
57
+ args.push(current);
58
+ }
59
+
60
+ return args;
61
+ }
62
+
63
+ /**
64
+ * Register a command by name and all aliases
65
+ */
66
+ export function registerCommand(cmd: Command): void {
67
+ commands.set(cmd.name, cmd);
68
+ commandsByName.set(cmd.name, cmd);
69
+
70
+ for (const alias of cmd.aliases) {
71
+ commands.set(alias, cmd);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Parse and execute a command
77
+ * Returns true if input was a command (even if unknown), false otherwise
78
+ */
79
+ export async function parseAndExecute(
80
+ input: string,
81
+ ctx: CommandContext
82
+ ): Promise<boolean> {
83
+ if (!input.startsWith("/")) {
84
+ return false;
85
+ }
86
+
87
+ const commandInput = input.slice(1);
88
+ const tokens = parseCommandLine(commandInput);
89
+
90
+ if (tokens.length === 0) {
91
+ return true;
92
+ }
93
+
94
+ let commandName = tokens[0];
95
+ let hasForce = false;
96
+
97
+ if (commandName.endsWith("!")) {
98
+ hasForce = true;
99
+ commandName = commandName.slice(0, -1);
100
+ }
101
+
102
+ const command = commands.get(commandName);
103
+
104
+ if (!command) {
105
+ ctx.showNotification(`Unknown command: /${commandName}`, "error");
106
+ return true;
107
+ }
108
+
109
+ const args = tokens.slice(1);
110
+
111
+ if (hasForce && !args.includes("--force")) {
112
+ args.unshift("--force");
113
+ }
114
+
115
+ try {
116
+ await command.execute(args, ctx);
117
+ } catch (error) {
118
+ const errorMsg = error instanceof Error ? error.message : String(error);
119
+ ctx.showNotification(`Command failed: ${errorMsg}`, "error");
120
+ }
121
+
122
+ return true;
123
+ }
124
+
125
+ /**
126
+ * Get all registered commands (no duplicates from aliases)
127
+ */
128
+ export function getAllCommands(): Command[] {
129
+ return Array.from(commandsByName.values());
130
+ }
@@ -0,0 +1,39 @@
1
+ import type { Command } from "./registry";
2
+
3
+ export const resumeCommand: Command = {
4
+ name: "resume",
5
+ aliases: ["unpause"],
6
+ description: "Resume a paused persona",
7
+ usage: "/resume [persona]",
8
+ execute: async (args, ctx) => {
9
+ let personaId: string | null;
10
+
11
+ if (args.length > 0) {
12
+ personaId = await ctx.ei.resolvePersonaName(args.join(" "));
13
+ if (!personaId) {
14
+ ctx.showNotification(`Persona '${args.join(" ")}' not found`, "error");
15
+ return;
16
+ }
17
+ } else {
18
+ personaId = ctx.ei.activePersonaId();
19
+ if (!personaId) {
20
+ ctx.showNotification("No persona selected", "error");
21
+ return;
22
+ }
23
+ }
24
+
25
+ const persona = ctx.ei.personas().find(p => p.id === personaId);
26
+ if (!persona) {
27
+ ctx.showNotification("Persona not found", "error");
28
+ return;
29
+ }
30
+
31
+ if (!persona.is_paused) {
32
+ ctx.showNotification(`${persona.display_name} is not paused`, "warn");
33
+ return;
34
+ }
35
+
36
+ await ctx.ei.updatePersona(personaId, { is_paused: false, pause_until: undefined });
37
+ ctx.showNotification(`Resumed ${persona.display_name}`, "info");
38
+ },
39
+ };
@@ -0,0 +1,43 @@
1
+ import type { Command } from "./registry.js";
2
+ import { ConfirmOverlay } from "../components/ConfirmOverlay.js";
3
+
4
+ export const setSyncCommand: Command = {
5
+ name: "setsync",
6
+ aliases: ["ss"],
7
+ description: "Set sync credentials (requires restart)",
8
+ usage: "/setsync <username> <passphrase>",
9
+
10
+ async execute(args, ctx) {
11
+ if (args.length < 2) {
12
+ ctx.showNotification("Usage: /setsync <username> <passphrase>", "error");
13
+ return;
14
+ }
15
+
16
+ const [username, passphrase] = args;
17
+
18
+ const confirmed = await new Promise<boolean>((resolve) => {
19
+ ctx.showOverlay((hideOverlay) => (
20
+ <ConfirmOverlay
21
+ message={`Set sync credentials for "${username}"?\n\nThis requires a restart. Just re-run ei once it closes!`}
22
+ onConfirm={() => {
23
+ hideOverlay();
24
+ resolve(true);
25
+ }}
26
+ onCancel={() => {
27
+ hideOverlay();
28
+ resolve(false);
29
+ }}
30
+ />
31
+ ));
32
+ });
33
+
34
+ if (!confirmed) {
35
+ ctx.showNotification("Sync setup cancelled", "info");
36
+ return;
37
+ }
38
+
39
+ await ctx.ei.updateSettings({ sync: { username, passphrase } });
40
+ ctx.showNotification("Sync credentials saved, restarting...", "info");
41
+ await ctx.exitApp();
42
+ },
43
+ };