ei-tui 0.6.4 → 0.6.6

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.
@@ -6,37 +6,73 @@ import { ConfirmOverlay } from "../components/ConfirmOverlay.js";
6
6
 
7
7
  type DataType = "facts" | "topics" | "people";
8
8
 
9
- const VALID_TYPES: DataType[] = ["facts", "topics", "people"];
9
+ const TYPE_ALIASES: Record<string, DataType> = {
10
+ facts: "facts", fact: "facts",
11
+ topics: "topics", topic: "topics",
12
+ people: "people", person: "people", persons: "people",
13
+ };
10
14
 
11
15
  export const meCommand: Command = {
12
16
  name: "me",
13
17
  aliases: [],
14
18
  description: "Edit your data in $EDITOR",
15
- usage: "/me [facts|topics|people]",
16
-
19
+ usage: "/me [fact|topic|person] [new | <search>]",
20
+
17
21
  async execute(args, ctx) {
18
22
  const human = await ctx.ei.getHuman();
19
-
20
- const filterArg = args[0]?.toLowerCase();
21
- const filterType: DataType | null = filterArg && VALID_TYPES.includes(filterArg as DataType)
22
- ? filterArg as DataType
23
- : null;
24
-
25
- if (filterArg && !filterType) {
26
- ctx.showNotification(`Invalid type: ${filterArg}. Use: facts, topics, people`, "error");
23
+
24
+ const typeArg = args[0]?.toLowerCase();
25
+ const filterType: DataType | null = typeArg ? (TYPE_ALIASES[typeArg] ?? null) : null;
26
+
27
+ if (typeArg && !filterType) {
28
+ ctx.showNotification(`Unknown type: ${typeArg}. Use: fact, topic, person`, "error");
27
29
  return;
28
30
  }
29
-
31
+
32
+ const secondArg = args[1]?.toLowerCase();
33
+ const isNew = secondArg === "new";
34
+ const searchTerm = !isNew && secondArg ? args.slice(1).join(" ") : null;
35
+
36
+ if (isNew && args.length > 2) {
37
+ ctx.showNotification(
38
+ `Use /me ${typeArg} new to create, or /me ${typeArg} ${args.slice(2).join(" ")} to search`,
39
+ "error"
40
+ );
41
+ return;
42
+ }
43
+
44
+ if ((isNew || searchTerm) && !filterType) {
45
+ ctx.showNotification(`Specify a type first: /me fact|topic|person [new | <search>]`, "error");
46
+ return;
47
+ }
48
+
49
+ const filterItems = <T extends { name: string }>(items: T[]): T[] => {
50
+ if (isNew) return [];
51
+ if (searchTerm) return items.filter(i => i.name.toLowerCase().includes(searchTerm.toLowerCase()));
52
+ return items;
53
+ };
54
+
30
55
  const filteredHuman = filterType ? {
31
56
  ...human,
32
- facts: filterType === "facts" ? human.facts : [],
33
- topics: filterType === "topics" ? human.topics : [],
34
- people: filterType === "people" ? human.people : [],
57
+ facts: filterType === "facts" ? filterItems(human.facts) : [],
58
+ topics: filterType === "topics" ? filterItems(human.topics) : [],
59
+ people: filterType === "people" ? filterItems(human.people) : [],
35
60
  } : human;
61
+
62
+ const isEmpty = filteredHuman.facts.length === 0
63
+ && filteredHuman.topics.length === 0
64
+ && filteredHuman.people.length === 0;
65
+
66
+ if (searchTerm && isEmpty) {
67
+ ctx.showNotification(`No ${filterType} matching "${searchTerm}" — open editor to create one`, "info");
68
+ }
36
69
 
37
70
  const personaLookup = new Map(ctx.ei.personas().map(p => [p.id, p.display_name]));
38
71
  const allGroups = await ctx.ei.getGroupList();
39
- let yamlContent = humanToYAML(filteredHuman, personaLookup, allGroups);
72
+ const sections = filterType
73
+ ? new Set<"facts" | "topics" | "people">([filterType])
74
+ : new Set<"facts" | "topics" | "people">(["facts", "topics", "people"]);
75
+ let yamlContent = humanToYAML(filteredHuman, personaLookup, allGroups, sections);
40
76
  let editorIteration = 0;
41
77
 
42
78
  while (true) {
@@ -67,7 +103,8 @@ export const meCommand: Command = {
67
103
  }
68
104
 
69
105
  try {
70
- const parsed = humanFromYAML(result.content, filteredHuman);
106
+ const currentHuman = await ctx.ei.getHuman();
107
+ const parsed = humanFromYAML(result.content, filteredHuman, currentHuman);
71
108
 
72
109
  for (const id of parsed.deletedFactIds) {
73
110
  await ctx.ei.removeDataItem("fact", id);
@@ -95,14 +132,20 @@ export const meCommand: Command = {
95
132
  }
96
133
  }
97
134
 
98
- const deleteCount = parsed.deletedFactIds.length +
99
- parsed.deletedTopicIds.length +
135
+ const deleteCount = parsed.deletedFactIds.length +
136
+ parsed.deletedTopicIds.length +
100
137
  parsed.deletedPersonIds.length;
101
- const updateCount = parsed.changedFactIds.size +
102
- parsed.changedTopicIds.size +
138
+ const updateCount = parsed.changedFactIds.size +
139
+ parsed.changedTopicIds.size +
103
140
  parsed.changedPersonIds.size;
104
-
105
- ctx.showNotification(`Updated ${updateCount} items, deleted ${deleteCount}`, "info");
141
+ const skippedCount = parsed.skippedFactCount +
142
+ parsed.skippedTopicCount +
143
+ parsed.skippedPersonCount;
144
+
145
+ const msg = skippedCount > 0
146
+ ? `Updated ${updateCount}, deleted ${deleteCount}, skipped ${skippedCount} (changed by another process)`
147
+ : `Updated ${updateCount} items, deleted ${deleteCount}`;
148
+ ctx.showNotification(msg, "info");
106
149
  return;
107
150
 
108
151
  } catch (parseError) {
@@ -1,10 +1,13 @@
1
1
  import { useKeyboard } from "@opentui/solid";
2
- import { For, onMount, onCleanup } from "solid-js";
3
- import { getAllCommands } from "../commands/registry";
2
+ import { onMount, onCleanup } from "solid-js";
3
+ import type { CliRenderer } from "@opentui/core";
4
4
  import { useKeyboardNav } from "../context/keyboard.js";
5
+ import { spawnPager } from "../util/editor.js";
6
+ import { buildManPage } from "../util/help-content.js";
5
7
 
6
8
  interface HelpOverlayProps {
7
9
  onDismiss: () => void;
10
+ renderer: CliRenderer;
8
11
  }
9
12
 
10
13
  export function HelpOverlay(props: HelpOverlayProps) {
@@ -14,11 +17,14 @@ export function HelpOverlay(props: HelpOverlayProps) {
14
17
 
15
18
  useKeyboard((event) => {
16
19
  event.preventDefault();
17
- props.onDismiss();
20
+ if (event.name === "m") {
21
+ props.onDismiss();
22
+ void spawnPager(buildManPage(), props.renderer);
23
+ } else {
24
+ props.onDismiss();
25
+ }
18
26
  });
19
27
 
20
- const commands = getAllCommands();
21
-
22
28
  return (
23
29
  <box
24
30
  position="absolute"
@@ -31,43 +37,67 @@ export function HelpOverlay(props: HelpOverlayProps) {
31
37
  justifyContent="center"
32
38
  >
33
39
  <box
34
- width={60}
40
+ width={82}
35
41
  backgroundColor="#1a1a2e"
36
42
  borderStyle="single"
37
43
  borderColor="#586e75"
38
44
  padding={2}
39
45
  flexDirection="column"
46
+ gap={1}
40
47
  >
41
48
 
42
- <text fg="#eee8d5">
43
- Commands:
44
- </text>
45
- <For each={commands.sort()}>
46
- {(cmd) => (
47
- <text fg="#93a1a1">
48
- /{cmd.name} - {cmd.description}
49
- </text>
50
- )}
51
- </For>
52
- <text> </text>
49
+ <box flexDirection="row" gap={2}>
50
+
51
+ <box flexDirection="column" gap={1} width={38}>
52
+ <box flexDirection="column">
53
+ <text fg="#eee8d5">Keybindings</text>
54
+ <text fg="#93a1a1"> Ctrl+E Open $EDITOR (preserves input)</text>
55
+ <text fg="#93a1a1"> Ctrl+C Clear input / exit</text>
56
+ <text fg="#93a1a1"> Ctrl+B Toggle sidebar</text>
57
+ <text fg="#93a1a1"> Escape Abort / resume queue</text>
58
+ <text fg="#93a1a1"> PgUp/Dn Scroll messages</text>
59
+ </box>
60
+
61
+ <box flexDirection="column">
62
+ <text fg="#eee8d5">Core</text>
63
+ <text fg="#93a1a1"> /set Edit global settings</text>
64
+ <text fg="#93a1a1"> /q /q! Quit (! = skip sync)</text>
65
+ <text fg="#93a1a1"> /provider Manage LLM providers</text>
66
+ <text fg="#93a1a1"> /me Edit your data</text>
67
+ <text fg="#93a1a1"> /d /d &lt;name&gt; Edit persona details</text>
68
+ </box>
69
+ </box>
70
+
71
+ <box flexDirection="column" gap={1} width={38}>
72
+ <box flexDirection="column">
73
+ <text fg="#eee8d5">Persona</text>
74
+ <text fg="#93a1a1"> /p /p new /p update</text>
75
+ <text fg="#93a1a1"> /context Message context</text>
76
+ <text fg="#93a1a1"> /pause /resume</text>
77
+ </box>
78
+
79
+ <box flexDirection="column">
80
+ <text fg="#eee8d5">Rooms</text>
81
+ <text fg="#93a1a1"> /r /r new Room picker / create</text>
82
+ <text fg="#93a1a1"> /activate Advance active node</text>
83
+ <text fg="#93a1a1"> /silence Pass your turn</text>
84
+ </box>
85
+
86
+ <box flexDirection="column">
87
+ <text fg="#eee8d5">Extended</text>
88
+ <text fg="#93a1a1"> /tools Tool providers</text>
89
+ <text fg="#93a1a1"> /auth spotify Spotify OAuth</text>
90
+ <text fg="#93a1a1"> /queue /dlq Inspect queues</text>
91
+ </box>
92
+ </box>
93
+
94
+ </box>
53
95
 
54
- <text fg="#eee8d5">
55
- Keybindings:
56
- </text>
57
- <text fg="#93a1a1">Escape - Abort operation / Resume queue</text>
58
- <text fg="#93a1a1">Ctrl+C - Clear input / Exit</text>
59
- <text fg="#93a1a1">Ctrl+B - Toggle sidebar</text>
60
- <text fg="#93a1a1">Ctrl+E - Edit in $EDITOR</text>
61
- <text fg="#93a1a1">PageUp/Down - Scroll messages</text>
62
- <text> </text>
96
+ <box flexDirection="column">
97
+ <text fg="#586e75"> m - full manual | any key - dismiss</text>
98
+ <text fg="#2a2a3e"> Ei - 永 (ei) - eternal</text>
99
+ </box>
63
100
 
64
- <text fg="#586e75">
65
- Press any key to dismiss
66
- </text>
67
- <text> </text>
68
- <text fg="#2a2a3e">
69
- Ei - 永 (ei) - eternal
70
- </text>
71
101
  </box>
72
102
  </box>
73
103
  );
@@ -14,6 +14,7 @@ import { Processor } from "../../../src/core/processor.js";
14
14
  import { FileStorage } from "../storage/file.js";
15
15
  import { remoteSync } from "../../../src/storage/remote.js";
16
16
  import { logger, clearLog, interceptConsole } from "../util/logger.js";
17
+ import { E2E_SKIP_LOCAL_DETECT } from "../util/e2e-flags.js";
17
18
  import { ConflictOverlay } from "../components/ConflictOverlay.js";
18
19
  import type {
19
20
  Ei_Interface,
@@ -698,7 +699,10 @@ export const EiProvider: ParentComponent = (props) => {
698
699
  try {
699
700
  const human = await processor!.getHuman();
700
701
  const hasAccounts = human.settings?.accounts && human.settings.accounts.length > 0;
701
- if (!hasAccounts) {
702
+ if (!hasAccounts && E2E_SKIP_LOCAL_DETECT) {
703
+ logger.info("E2E_SKIP_LOCAL_DETECT active, skipping local LLM check");
704
+ setShowWelcomeOverlay(true);
705
+ } else if (!hasAccounts) {
702
706
  logger.info("No LLM accounts configured, checking for local LLM...");
703
707
  try {
704
708
  const response = await fetch("http://127.0.0.1:1234/v1/models", {
@@ -707,6 +711,7 @@ export const EiProvider: ParentComponent = (props) => {
707
711
  });
708
712
  if (response.ok) {
709
713
  logger.info("Local LLM detected, auto-configuring...");
714
+ const defaultModelId = crypto.randomUUID();
710
715
  const localAccount: ProviderAccount = {
711
716
  id: crypto.randomUUID(),
712
717
  name: "Local LLM",
@@ -714,13 +719,15 @@ export const EiProvider: ParentComponent = (props) => {
714
719
  url: "http://127.0.0.1:1234/v1",
715
720
  enabled: true,
716
721
  created_at: new Date().toISOString(),
722
+ default_model: defaultModelId,
723
+ models: [{ id: defaultModelId, name: "(default)" }],
717
724
  };
718
725
  const currentHuman = await processor!.getHuman();
719
726
  await processor!.updateHuman({
720
727
  settings: {
721
728
  ...currentHuman.settings,
722
729
  accounts: [localAccount],
723
- default_model: "Local LLM",
730
+ default_model: defaultModelId,
724
731
  },
725
732
  });
726
733
  showNotification("Local LLM detected and configured!", "info");
@@ -33,8 +33,10 @@ export const OverlayProvider: ParentComponent = (props) => {
33
33
  const hideOverlay = () => {
34
34
  logger.debug("[overlay] hideOverlay called");
35
35
  setOverlayRenderer(null);
36
+ cliRenderer?.requestRender();
36
37
  };
37
38
  setOverlayRenderer(() => () => renderer(hideOverlay, hideForEditor));
39
+ queueMicrotask(() => cliRenderer?.requestRender());
38
40
  };
39
41
 
40
42
  const hideOverlay = () => {
package/tui/src/index.tsx CHANGED
@@ -4,6 +4,13 @@ import { App } from "./app";
4
4
 
5
5
  import { InstanceLock } from "./util/instance-lock";
6
6
  import { FileStorage } from "./storage/file";
7
+ import pkg from "../../package.json";
8
+
9
+ const args = process.argv.slice(2);
10
+ if (args.includes("--version") || args.includes("version") || args.includes("-v")) {
11
+ process.stdout.write(`${pkg.version}\n`);
12
+ process.exit(0);
13
+ }
7
14
 
8
15
  const storage = new FileStorage(Bun.env.EI_DATA_PATH);
9
16
  const lock = new InstanceLock(storage.getDataPath());
@@ -0,0 +1,13 @@
1
+ /**
2
+ * EI_E2E_MODE — bitfield for test seams that can't be solved via data seeding.
3
+ *
4
+ * Use prime-power bits so combinations are unambiguous:
5
+ * 1 — skip local LLM auto-detect (fetch to :1234/:11434)
6
+ * 2 — (reserved for next scenario)
7
+ * 3 — flags 1 + 2 combined
8
+ *
9
+ * Production code should never set this. Tests pass it via env in test.use({ env: { EI_E2E_MODE: "1" } }).
10
+ */
11
+ const E2E_MODE = parseInt(process.env.EI_E2E_MODE ?? "0", 10);
12
+
13
+ export const E2E_SKIP_LOCAL_DETECT = (E2E_MODE & 1) !== 0;
@@ -23,6 +23,41 @@ export interface EditorResult {
23
23
  aborted: boolean;
24
24
  }
25
25
 
26
+ export async function spawnPager(content: string, renderer: CliRenderer): Promise<void> {
27
+ const pager = process.env.PAGER || "less";
28
+ const tmpDir = os.tmpdir();
29
+ const tmpFile = path.join(tmpDir, `ei-help-${Date.now()}.txt`);
30
+
31
+ fs.writeFileSync(tmpFile, content, "utf-8");
32
+
33
+ await new Promise(resolve => setTimeout(resolve, 50));
34
+
35
+ return new Promise((resolve) => {
36
+ renderer.suspend();
37
+ renderer.currentRenderBuffer.clear();
38
+
39
+ const child = spawn(pager, [tmpFile], {
40
+ stdio: "inherit",
41
+ shell: true,
42
+ });
43
+
44
+ child.on("error", () => {
45
+ try { fs.unlinkSync(tmpFile); } catch {}
46
+ renderer.resume();
47
+ renderer.requestRender();
48
+ resolve();
49
+ });
50
+
51
+ child.on("exit", () => {
52
+ try { fs.unlinkSync(tmpFile); } catch {}
53
+ renderer.currentRenderBuffer.clear();
54
+ renderer.resume();
55
+ renderer.requestRender();
56
+ resolve();
57
+ });
58
+ });
59
+ }
60
+
26
61
  export async function spawnEditorRaw(options: EditorRawOptions): Promise<EditorResult> {
27
62
  const { initialContent, filename } = options;
28
63
  const editor = process.env.EDITOR || process.env.VISUAL || "vi";
@@ -152,9 +187,7 @@ export async function spawnEditor(options: EditorOptions): Promise<EditorResult>
152
187
  logger.debug("[editor] already suspended before spawn, skipping resume");
153
188
  }
154
189
 
155
- queueMicrotask(() => {
156
- renderer.requestRender();
157
- });
190
+ renderer.requestRender();
158
191
 
159
192
  if (code !== 0) {
160
193
  try { fs.unlinkSync(tmpFile); } catch {}
@@ -0,0 +1,136 @@
1
+ export function buildManPage(): string {
2
+ return `EI(1) Ei Terminal UI EI(1)
3
+
4
+ NAME
5
+ ei - local-first AI companion with persistent personas
6
+
7
+ KEYBINDINGS
8
+ Ctrl+E Open current input in $EDITOR (preserves text)
9
+ Ctrl+C Clear input field (second press exits)
10
+ Ctrl+B Toggle sidebar
11
+ Escape Abort operation / resume queue
12
+ PageUp/Down Scroll message history
13
+
14
+ CORE COMMANDS
15
+ /settings, /set
16
+ Edit global settings in $EDITOR. Configure default model,
17
+ heartbeat interval, context window, integrations, and more.
18
+
19
+ /quit, /q
20
+ Save, sync, and exit. Add ! to force quit without syncing: /q!
21
+
22
+ /provider
23
+ Open provider picker to select, edit, or create LLM providers.
24
+ /provider <Name> Set provider on active persona
25
+ /provider <Name>:<Model> Set provider and model explicitly
26
+ /provider new Create a new provider
27
+
28
+ /me
29
+ Edit your personal data (facts, topics, people) in $EDITOR.
30
+ Each section includes a commented stub — uncomment and fill it in
31
+ to create a new entry. No UUID required; one is generated for you.
32
+
33
+ /me fact Edit only facts (stub included)
34
+ /me topic Edit only topics (stub included)
35
+ /me person Edit only people (stub included)
36
+ /me fact new Open with just the new-fact stub
37
+ /me fact coffee Filter facts whose name contains "coffee"
38
+ /me person "New York" Quoted search for multi-word names
39
+
40
+ /details, /d
41
+ Edit the current persona's details in $EDITOR.
42
+ /d <name> Edit a specific persona by name
43
+
44
+ PERSONA COMMANDS
45
+ /persona, /p
46
+ Open persona picker. Switch, list, or create personas.
47
+ /p new <name> Create a new persona
48
+ /p update <name> [person] Regenerate persona from a person record
49
+
50
+ /context, /messages
51
+ Edit which messages are included in LLM context.
52
+
53
+ /pause
54
+ Pause the current persona indefinitely.
55
+ /pause <duration> Pause for a duration: 2h, 1d, 1w
56
+
57
+ /resume, /unpause
58
+ Resume the current paused persona.
59
+ /resume <name> Resume a specific persona
60
+
61
+ /new
62
+ Toggle a context boundary — starts a fresh conversation thread
63
+ without deleting history.
64
+
65
+ /quotes, /quote
66
+ Manage quotes attached to messages.
67
+ /quotes <N> View quotes from message N
68
+ /quotes me View your own quotes
69
+ /quotes search "term" Search quotes by keyword
70
+ /quotes <persona> View a persona's quotes
71
+
72
+ ROOM COMMANDS
73
+ /room, /r
74
+ Open room picker. Switch or create rooms.
75
+ /room new Create a new room (FFA, CYP, or MAP mode)
76
+ /room new <name> Create with a pre-filled name
77
+
78
+ /activate, /a
79
+ Advance the active node in a CYP or MAP room.
80
+ /activate <num> Activate a specific response by number
81
+
82
+ /silence
83
+ Pass your turn in a room with an optional reason.
84
+ /silence [reason]
85
+
86
+ /capture
87
+ Force-extract quotes, topics, and people from the current chat now.
88
+
89
+ EXTENDED COMMANDS
90
+ /tools
91
+ Manage tool providers — enable or disable tools per persona.
92
+
93
+ /auth
94
+ Authenticate with an external service.
95
+ /auth spotify Connect your Spotify account
96
+
97
+ /queue
98
+ Pause the queue and inspect or edit active items in $EDITOR.
99
+
100
+ /dlq
101
+ Inspect and recover failed (dead-letter) queue items in $EDITOR.
102
+
103
+ /dedupe
104
+ Find and merge duplicate people or topics.
105
+ /dedupe person Flare "Jeremy Scherer"
106
+ /dedupe topic AI "artificial intelligence"
107
+
108
+ /archive
109
+ Archive a persona or room. Lists archived items if no name given.
110
+ /archive <name> Archive by name
111
+
112
+ /unarchive
113
+ Restore an archived persona or room and switch to it.
114
+ /unarchive <name>
115
+
116
+ /delete, /del
117
+ Permanently delete a persona. Cannot be undone.
118
+ /delete <name>
119
+
120
+ /setsync, /ss
121
+ Set sync credentials (triggers restart).
122
+ /setsync <username> <passphrase>
123
+
124
+ /editor, /e, /edit
125
+ Open $EDITOR with the current input field contents.
126
+ Note: Ctrl+E does the same thing without clearing the input first.
127
+
128
+ TIPS
129
+ - Append ! to any command as shorthand for --force: /quit!
130
+ - Duration strings: 30m, 2h, 1d, 1w (used by /pause, /settings)
131
+ - All editor fields that say "null" inherit from your global settings
132
+ - $EDITOR and $PAGER are respected throughout
133
+
134
+ Ei - 永 (ei) - eternal
135
+ `;
136
+ }