ei-tui 0.1.3 → 0.1.5

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 (44) hide show
  1. package/README.md +36 -35
  2. package/package.json +6 -2
  3. package/src/README.md +85 -1
  4. package/src/cli/README.md +30 -20
  5. package/src/cli/retrieval.ts +5 -17
  6. package/src/cli.ts +69 -0
  7. package/src/core/handlers/index.ts +195 -172
  8. package/src/core/orchestrators/ceremony.ts +4 -4
  9. package/src/core/orchestrators/extraction-chunker.ts +3 -3
  10. package/src/core/processor.ts +245 -77
  11. package/src/core/queue-processor.ts +3 -26
  12. package/src/core/state/checkpoints.ts +4 -0
  13. package/src/core/state/personas.ts +13 -1
  14. package/src/core/state/queue.ts +80 -23
  15. package/src/core/state-manager.ts +36 -10
  16. package/src/core/types.ts +23 -11
  17. package/src/core/utils/crossFind.ts +44 -0
  18. package/src/core/utils/index.ts +4 -0
  19. package/src/integrations/opencode/importer.ts +118 -691
  20. package/src/prompts/heartbeat/check.ts +27 -13
  21. package/src/prompts/heartbeat/ei.ts +65 -136
  22. package/src/prompts/heartbeat/types.ts +47 -17
  23. package/src/prompts/human/item-update.ts +20 -8
  24. package/src/prompts/index.ts +2 -5
  25. package/src/prompts/message-utils.ts +42 -3
  26. package/src/prompts/response/index.ts +13 -6
  27. package/src/prompts/response/sections.ts +65 -12
  28. package/src/prompts/response/types.ts +10 -0
  29. package/tui/README.md +89 -4
  30. package/tui/src/commands/dlq.ts +75 -0
  31. package/tui/src/commands/editor.tsx +1 -1
  32. package/tui/src/commands/queue.ts +77 -0
  33. package/tui/src/components/CommandSuggest.tsx +50 -0
  34. package/tui/src/components/MessageList.tsx +12 -2
  35. package/tui/src/components/PromptInput.tsx +118 -30
  36. package/tui/src/components/Sidebar.tsx +6 -2
  37. package/tui/src/components/StatusBar.tsx +12 -5
  38. package/tui/src/context/ei.tsx +43 -3
  39. package/tui/src/context/keyboard.tsx +90 -2
  40. package/tui/src/util/clipboard.ts +73 -0
  41. package/tui/src/util/yaml-serializers.ts +81 -11
  42. package/src/prompts/validation/ei.ts +0 -93
  43. package/src/prompts/validation/index.ts +0 -6
  44. package/src/prompts/validation/types.ts +0 -22
@@ -29,17 +29,29 @@ ${description}`;
29
29
  // =============================================================================
30
30
 
31
31
  export function buildGuidelinesSection(personaName: string): string {
32
- const universal = `## Guidelines
33
- - Be genuine, not sycophantic - a good friend disagrees when appropriate
34
- - Match conversational energy - brief replies for brief messages
35
- - Respect boundaries - it's okay to say nothing if silence is appropriate
36
- - You're a friend with your own interests, not just a helper`;
37
-
38
- if (personaName.toLowerCase() === "ei") {
39
- return `${universal}
40
- - Encourage human-to-human connection when appropriate
41
- - Be transparent about being an AI when relevant
42
- - Gently challenge self-limiting beliefs - growth over comfort`;
32
+ const silenceGuidance = [
33
+ '- **Silence is a valid response** - real friends don\'t always have something to say. Stay silent when:',
34
+ ' - You sent the last message and the human is just acknowledging it ("good night", "thanks", "lol", "ok")',
35
+ ' - The human is venting or in the middle of a thought - sometimes a nod is all that\'s needed',
36
+ ' - The question or statement is rhetorical - not everything deserves an answer',
37
+ ' - Responding would feel forced, performative, or hollow',
38
+ ].join('\n');
39
+
40
+ const universal = [
41
+ '## Guidelines',
42
+ '- Be genuine, not sycophantic - a good friend disagrees when appropriate',
43
+ '- Match conversational energy - brief replies for brief messages',
44
+ '- You\'re a friend with your own interests, not just a helper',
45
+ silenceGuidance,
46
+ ].join('\n');
47
+
48
+ if (personaName.toLowerCase() === 'ei') {
49
+ return [
50
+ universal,
51
+ '- Encourage human-to-human connection when appropriate',
52
+ '- Be transparent about being an AI when relevant',
53
+ '- Gently challenge self-limiting beliefs - growth over comfort',
54
+ ].join('\n');
43
55
  }
44
56
 
45
57
  return universal;
@@ -280,7 +292,7 @@ export function buildQuotesSection(quotes: Quote[], human: ResponsePromptData["h
280
292
 
281
293
  return `## Memorable Moments
282
294
 
283
- These are quotes the human found worth preserving:
295
+ These are quotes the human or the system found worth preserving:
284
296
 
285
297
  ${formatted}`;
286
298
  }
@@ -353,3 +365,44 @@ The human can view and edit all of this by ${seeHumanDataAction}.
353
365
  - If they want you to remember something specific, tell them about the quote capture feature (${viewQuotesAction})
354
366
  - Pausing the system (Escape) immediately stops AI processing but preserves messages`;
355
367
  }
368
+
369
+ // =============================================================================
370
+ // RESPONSE FORMAT SECTION
371
+ // =============================================================================
372
+
373
+ export function buildResponseFormatSection(): string {
374
+ const jsonResponding = [
375
+ '{',
376
+ ' "should_respond": true,',
377
+ ' "verbal_response": "What you would say out loud",',
378
+ ' "action_response": "What you would do (rendered in italics, like stage directions)"',
379
+ '}'
380
+ ].join('\n');
381
+
382
+ const jsonSilent = [
383
+ '{',
384
+ ' "should_respond": false,',
385
+ ' "reason": "Brief explanation of why silence is the right choice here"',
386
+ '}'
387
+ ].join('\n');
388
+
389
+ return `## Response Format
390
+
391
+ Always respond with JSON in this exact format:
392
+
393
+ \`\`\`json
394
+ ${jsonResponding}
395
+ \`\`\`
396
+
397
+ Or, if staying silent:
398
+
399
+ \`\`\`json
400
+ ${jsonSilent}
401
+ \`\`\`
402
+
403
+ Rules:
404
+ - \`verbal_response\` and \`action_response\` are both optional - include whichever applies
405
+ - \`reason\` is only used when \`should_respond\` is false
406
+ - Do NOT include \`<thinking>\` blocks or analysis outside the JSON
407
+ - The JSON must be valid - use double quotes, no trailing commas`;
408
+ }
@@ -29,6 +29,16 @@ export interface ResponsePromptData {
29
29
  isTUI: boolean;
30
30
  }
31
31
 
32
+ /**
33
+ * Structured response from LLM (new JSON schema)
34
+ */
35
+ export interface PersonaResponseResult {
36
+ should_respond: boolean;
37
+ verbal_response?: string;
38
+ action_response?: string;
39
+ reason?: string;
40
+ }
41
+
32
42
  /**
33
43
  * Prompt output structure (all prompts return this)
34
44
  */
package/tui/README.md CHANGED
@@ -1,18 +1,103 @@
1
1
  # Terminal User Interface (TUI)
2
2
 
3
- EI TUI is built with OpenTUI and SolidJS.
3
+ Ei TUI is built with OpenTUI and SolidJS.
4
4
 
5
- Offering Opencode integration via import (`/settings` -> opencode.integration: true) and export: [CLI](../src/cli/README.md)
5
+ OpenCode integration: import via `/settings` (`opencode.integration: true`) · export via [CLI](../src/cli/README.md)
6
6
 
7
7
  # Installation
8
8
 
9
- ```
9
+ ```bash
10
+ # Install Bun (if you don't have it)
11
+ curl -fsSL https://bun.sh/install | bash
12
+
13
+ # Install Ei
10
14
  npm install -g ei-tui
11
15
  ```
12
16
 
13
17
  ## TUI Commands
14
18
 
15
- Coming soon! In the TUI, you can do /h to see a quick list.
19
+ All commands start with `/`. Append `!` to any command as a shorthand for `--force` (e.g., `/quit!`).
20
+
21
+ ### Navigation & App
22
+
23
+ | Command | Aliases | Description |
24
+ |---------|---------|-------------|
25
+ | `/help` | `/h` | Show the command list and keybindings |
26
+ | `/quit` | `/q` | Save, sync, and exit |
27
+ | `/quit!` | `/q!` | Force quit without syncing |
28
+
29
+ ### Personas
30
+
31
+ | Command | Aliases | Description |
32
+ |---------|---------|-------------|
33
+ | `/persona` | `/p` | Open persona picker overlay |
34
+ | `/persona <name>` | `/p <name>` | Switch to a persona by name or alias |
35
+ | `/persona new <name>` | `/p new <name>` | Create a new persona (opens `$EDITOR`) |
36
+ | `/details` | `/d` | Edit the current persona in `$EDITOR` |
37
+ | `/details <name>` | `/d <name>` | Edit a specific persona in `$EDITOR` |
38
+ | `/archive` | | List archived personas (Enter to unarchive) |
39
+ | `/archive <name>` | | Archive a persona by name |
40
+ | `/unarchive <name>` | | Unarchive a persona and switch to it |
41
+ | `/delete` | `/del` | Pick a persona to permanently delete |
42
+ | `/delete <name>` | `/del <name>` | Permanently delete a persona by name (confirms) |
43
+ | `/pause` | | Pause current persona indefinitely |
44
+ | `/pause <duration>` | | Pause for a duration: `2h`, `1d`, `1w`, `30m` |
45
+ | `/resume` | `/unpause` | Resume the current paused persona |
46
+ | `/resume <name>` | `/unpause <name>` | Resume a specific paused persona |
47
+
48
+ ### Providers & Models
49
+
50
+ | Command | Aliases | Description |
51
+ |---------|---------|-------------|
52
+ | `/provider` | `/providers` | Open provider picker (select, edit, or create) |
53
+ | `/provider <name>` | | Set a provider on the active persona by name |
54
+ | `/provider new` | | Create a new LLM provider (opens `$EDITOR`) |
55
+ | `/model <model>` | | Set model for active persona (e.g., `sonnet-latest`) |
56
+ | `/model <provider:model>` | | Set provider + model explicitly (e.g., `openai:gpt-4o`) |
57
+
58
+ ### Messages & Context
59
+
60
+ | Command | Aliases | Description |
61
+ |---------|---------|-------------|
62
+ | `/new` | | Toggle context boundary (fresh conversation start) |
63
+ | `/context` | `/messages` | Edit message context status in `$EDITOR` |
64
+ | `/quotes` | `/quote` | Open all quotes in `$EDITOR` |
65
+ | `/quotes me` | | Open only your (human) quotes |
66
+ | `/quotes <N>` | | View/edit quotes attached to message number N |
67
+ | `/quotes search "term"` | | Search quotes by keyword |
68
+ | `/quotes <persona>` | | View/edit quotes attributed to a specific persona |
69
+
70
+ ### Data & Settings
71
+
72
+ | Command | Aliases | Description |
73
+ |---------|---------|-------------|
74
+ | `/me` | | Edit all your data (facts, traits, topics, people) in `$EDITOR` |
75
+ | `/me <type>` | | Edit one type: `facts`, `traits`, `topics`, or `people` |
76
+ | `/settings` | `/set` | Edit your global settings in `$EDITOR` |
77
+ | `/setsync <user> <pass>` | `/ss` | Set sync credentials (triggers restart) |
78
+
79
+ ### Editor
80
+
81
+ | Command | Aliases | Description |
82
+ |---------|---------|-------------|
83
+ | `/editor` | `/e`, `/edit` | Open current input text in `$EDITOR`, update on save |
84
+
85
+ ### Queue & Debugging
86
+
87
+ | Command | Aliases | Description |
88
+ |---------|---------|-------------|
89
+ | `/queue` | | Pause queue and inspect/edit active items in `$EDITOR` |
90
+ | `/dlq` | | Inspect and recover failed (dead-letter) queue items in `$EDITOR` |
91
+
92
+ ### Keybindings
93
+
94
+ | Key | Action |
95
+ |-----|--------|
96
+ | `Escape` | Abort current operation / resume queue |
97
+ | `Ctrl+C` | Clear input (second press exits) |
98
+ | `Ctrl+B` | Toggle sidebar |
99
+ | `Ctrl+E` | Open `$EDITOR` (preserves current input) |
100
+ | `PageUp / PageDown` | Scroll message history |
16
101
 
17
102
  # Development
18
103
 
@@ -0,0 +1,75 @@
1
+ import type { Command } from "./registry.js";
2
+ import { spawnEditor } from "../util/editor.js";
3
+ import { queueItemsToYAML, queueItemsFromYAML } from "../util/yaml-serializers.js";
4
+ import { ConfirmOverlay } from "../components/ConfirmOverlay.js";
5
+
6
+ export const dlqCommand: Command = {
7
+ name: "dlq",
8
+ aliases: [],
9
+ description: "Open dead-letter queue $EDITOR",
10
+ usage: "/dlq - Inspect and recover failed queue items",
11
+
12
+ async execute(_args, ctx) {
13
+ const items = ctx.ei.getDLQItems();
14
+
15
+ if (items.length === 0) {
16
+ ctx.showNotification("DLQ is empty", "info");
17
+ return;
18
+ }
19
+
20
+ let yamlContent = queueItemsToYAML(items);
21
+
22
+ while (true) {
23
+ const result = await spawnEditor({
24
+ initialContent: yamlContent,
25
+ filename: "ei-dlq.yaml",
26
+ renderer: ctx.renderer,
27
+ });
28
+
29
+ if (result.aborted || !result.success) {
30
+ ctx.showNotification("DLQ edit cancelled", "info");
31
+ return;
32
+ }
33
+
34
+ if (result.content === null) {
35
+ ctx.showNotification("No changes made", "info");
36
+ return;
37
+ }
38
+
39
+ try {
40
+ const updates = queueItemsFromYAML(result.content);
41
+ let recovered = 0;
42
+ for (const update of updates) {
43
+ await ctx.ei.updateQueueItem(update.id, update);
44
+ if (update.state === "pending") recovered++;
45
+ }
46
+ const msg = recovered > 0
47
+ ? `DLQ updated — ${recovered} item(s) requeued`
48
+ : `DLQ updated (no items requeued)`;
49
+ ctx.showNotification(msg, "info");
50
+ return;
51
+ } catch (parseError) {
52
+ const errorMsg = parseError instanceof Error ? parseError.message : String(parseError);
53
+
54
+ const shouldReEdit = await new Promise<boolean>((resolve) => {
55
+ ctx.showOverlay((hideOverlay) =>
56
+ ConfirmOverlay({
57
+ message: `YAML error:\n${errorMsg}\n\nRe-edit?`,
58
+ onConfirm: () => { hideOverlay(); resolve(true); },
59
+ onCancel: () => { hideOverlay(); resolve(false); },
60
+ })
61
+ );
62
+ });
63
+
64
+ if (shouldReEdit) {
65
+ yamlContent = result.content;
66
+ await new Promise(r => setTimeout(r, 50));
67
+ continue;
68
+ }
69
+
70
+ ctx.showNotification("Changes discarded", "warn");
71
+ return;
72
+ }
73
+ }
74
+ },
75
+ };
@@ -21,7 +21,7 @@ export const editorCommand: Command = {
21
21
 
22
22
  const result = await spawnEditor({
23
23
  initialContent: currentText,
24
- filename: "message.txt",
24
+ filename: "message.md",
25
25
  renderer: ctx.renderer,
26
26
  });
27
27
 
@@ -0,0 +1,77 @@
1
+ import type { Command } from "./registry.js";
2
+ import { spawnEditor } from "../util/editor.js";
3
+ import { queueItemsToYAML, queueItemsFromYAML } from "../util/yaml-serializers.js";
4
+ import { ConfirmOverlay } from "../components/ConfirmOverlay.js";
5
+
6
+ export const queueCommand: Command = {
7
+ name: "queue",
8
+ aliases: [],
9
+ description: "Pause & open queue items in $EDITOR",
10
+ usage: "/queue - Inspect and edit active queue items",
11
+
12
+ async execute(_args, ctx) {
13
+ const items = ctx.ei.getQueueActiveItems();
14
+
15
+ if (items.length === 0) {
16
+ ctx.showNotification("Queue is empty", "info");
17
+ return;
18
+ }
19
+
20
+ ctx.ei.pauseQueue();
21
+ ctx.showNotification(`Queue paused (${items.length} items)`, "info");
22
+
23
+ let yamlContent = queueItemsToYAML(items);
24
+
25
+ while (true) {
26
+ const result = await spawnEditor({
27
+ initialContent: yamlContent,
28
+ filename: "ei-queue.yaml",
29
+ renderer: ctx.renderer,
30
+ });
31
+
32
+ if (result.aborted || !result.success) {
33
+ ctx.ei.resumeQueue();
34
+ ctx.showNotification("Queue resumed (no changes)", "info");
35
+ return;
36
+ }
37
+
38
+ if (result.content === null) {
39
+ ctx.ei.resumeQueue();
40
+ ctx.showNotification("No changes — queue resumed", "info");
41
+ return;
42
+ }
43
+
44
+ try {
45
+ const updates = queueItemsFromYAML(result.content);
46
+ for (const update of updates) {
47
+ await ctx.ei.updateQueueItem(update.id, update);
48
+ }
49
+ ctx.ei.resumeQueue();
50
+ ctx.showNotification(`Queue updated (${updates.length} items) — resumed`, "info");
51
+ return;
52
+ } catch (parseError) {
53
+ const errorMsg = parseError instanceof Error ? parseError.message : String(parseError);
54
+
55
+ const shouldReEdit = await new Promise<boolean>((resolve) => {
56
+ ctx.showOverlay((hideOverlay) =>
57
+ ConfirmOverlay({
58
+ message: `YAML error:\n${errorMsg}\n\nRe-edit?`,
59
+ onConfirm: () => { hideOverlay(); resolve(true); },
60
+ onCancel: () => { hideOverlay(); resolve(false); },
61
+ })
62
+ );
63
+ });
64
+
65
+ if (shouldReEdit) {
66
+ yamlContent = result.content;
67
+ await new Promise(r => setTimeout(r, 50));
68
+ continue;
69
+ }
70
+
71
+ ctx.ei.resumeQueue();
72
+ ctx.showNotification("Changes discarded — queue resumed", "warn");
73
+ return;
74
+ }
75
+ }
76
+ },
77
+ };
@@ -0,0 +1,50 @@
1
+ import { createMemo, For } from "solid-js";
2
+ import { getAllCommands } from "../commands/registry";
3
+
4
+ interface CommandSuggestProps {
5
+ input: () => string;
6
+ highlightIndex: () => number;
7
+ }
8
+
9
+ export function CommandSuggest(props: CommandSuggestProps) {
10
+ const matches = createMemo(() => {
11
+ const raw = props.input().trim();
12
+ if (!raw.startsWith("/")) return [];
13
+ const query = raw.slice(1).split(/\s/)[0].replace(/!$/, "").toLowerCase();
14
+ return getAllCommands().filter(
15
+ (cmd) =>
16
+ cmd.name.startsWith(query) ||
17
+ cmd.aliases.some((a) => a.startsWith(query))
18
+ );
19
+ });
20
+
21
+ return (
22
+ <box
23
+ flexDirection="column"
24
+ visible={matches().length > 0}
25
+ borderStyle="single"
26
+ border={true}
27
+ borderColor="#586e75"
28
+ backgroundColor="#1a1a2e"
29
+ flexShrink={0}
30
+ >
31
+ <For each={matches()}>
32
+ {(cmd, i) => {
33
+ const isHighlighted = () => i() === props.highlightIndex();
34
+ const aliases =
35
+ cmd.aliases.length > 0 ? ` (/${cmd.aliases.join(", /")})` : "";
36
+ return (
37
+ <box
38
+ paddingX={1}
39
+ backgroundColor={isHighlighted() ? "#2d3748" : "transparent"}
40
+ >
41
+ <text fg={isHighlighted() ? "#eee8d5" : "#839496"} truncate>
42
+ {`/${cmd.name}${aliases} ${cmd.description}`}
43
+ </text>
44
+ </box>
45
+ );
46
+ }}
47
+ </For>
48
+ </box>
49
+ );
50
+ }
@@ -18,6 +18,16 @@ function formatTime(timestamp: string): string {
18
18
  return `${hours}:${minutes}`;
19
19
  }
20
20
 
21
+ function buildMessageText(message: Message): string {
22
+ if (message.silence_reason !== undefined) {
23
+ return `[chose not to respond: ${message.silence_reason}]`;
24
+ }
25
+ const parts: string[] = [];
26
+ if (message.action_response) parts.push(`_${message.action_response}_`);
27
+ if (message.verbal_response) parts.push(message.verbal_response);
28
+ return parts.join('\n\n');
29
+ }
30
+
21
31
  function insertQuoteMarkers(content: string, quotes: Quote[]): string {
22
32
  const validQuotes = quotes
23
33
  .filter(q => q.end !== null && q.end !== undefined)
@@ -30,7 +40,7 @@ function insertQuoteMarkers(content: string, quotes: Quote[]): string {
30
40
  while (insertPos > 0 && (result[insertPos - 1] === '\n' || result[insertPos - 1] === ' ')) {
31
41
  insertPos--;
32
42
  }
33
- result = result.slice(0, insertPos) + "" + result.slice(insertPos);
43
+ result = result.slice(0, insertPos) + "\u207a" + result.slice(insertPos);
34
44
  }
35
45
  }
36
46
  return result;
@@ -118,7 +128,7 @@ export function MessageList() {
118
128
 
119
129
  const header = () => `${speaker} (${formatTime(message.timestamp)}) [✂️ ${message._quoteIndex}]:`;
120
130
 
121
- const displayContent = insertQuoteMarkers(message.content, message._quotes);
131
+ const displayContent = insertQuoteMarkers(buildMessageText(message), message._quotes);
122
132
 
123
133
  const showDivider = () => {
124
134
  const boundary = activeContextBoundary();
@@ -1,3 +1,5 @@
1
+ import { createEffect, createSignal } from "solid-js";
2
+ import { getAllCommands } from "../commands/registry";
1
3
  import type { TextareaRenderable, KeyBinding } from "@opentui/core";
2
4
  import { useEi } from "../context/ei";
3
5
  import { useKeyboardNav } from "../context/keyboard";
@@ -19,7 +21,12 @@ import { deleteCommand } from "../commands/delete";
19
21
  import { quotesCommand } from "../commands/quotes";
20
22
  import { providerCommand } from "../commands/provider";
21
23
  import { setSyncCommand } from "../commands/setsync";
24
+ import { queueCommand } from "../commands/queue";
25
+ import { dlqCommand } from "../commands/dlq";
22
26
  import { useOverlay } from "../context/overlay";
27
+ import { CommandSuggest } from "./CommandSuggest";
28
+ import { useKeyboard } from "@opentui/solid";
29
+ import type { KeyEvent } from "@opentui/core";
23
30
 
24
31
  const TEXTAREA_KEYBINDINGS: KeyBinding[] = [
25
32
  { name: "return", action: "submit" },
@@ -30,7 +37,7 @@ const TEXTAREA_KEYBINDINGS: KeyBinding[] = [
30
37
  export function PromptInput() {
31
38
  const ei = useEi();
32
39
  const { sendMessage, activePersonaId, stopProcessor, showNotification } = ei;
33
- const { registerTextarea, registerEditorHandler, exitApp, renderer } = useKeyboardNav();
40
+ const { registerTextarea, registerEditorHandler, exitApp, renderer, resetHistoryIndex } = useKeyboardNav();
34
41
  const { showOverlay, hideOverlay, overlayRenderer } = useOverlay();
35
42
 
36
43
  registerCommand(helpCommand);
@@ -51,9 +58,70 @@ export function PromptInput() {
51
58
  registerCommand(setSyncCommand);
52
59
  registerCommand(contextCommand);
53
60
  registerCommand(deleteCommand);
61
+ registerCommand(queueCommand);
62
+ registerCommand(dlqCommand);
54
63
 
55
64
  let textareaRef: TextareaRenderable | undefined;
56
65
 
66
+ const [inputText, setInputText] = createSignal("");
67
+ const [suggestIndex, setSuggestIndex] = createSignal(0);
68
+
69
+ const suggestMatches = () => {
70
+ const raw = inputText().trim();
71
+ if (!raw.startsWith("/")) return [];
72
+ const query = raw.slice(1).split(/\s/)[0].replace(/!$/, "").toLowerCase();
73
+ return getAllCommands().filter(
74
+ (cmd) =>
75
+ cmd.name.startsWith(query) ||
76
+ cmd.aliases.some((a) => a.startsWith(query))
77
+ );
78
+ };
79
+
80
+ const suggestVisible = () => suggestMatches().length > 0 && !overlayRenderer();
81
+
82
+ createEffect(() => {
83
+ inputText();
84
+ setSuggestIndex(0);
85
+ });
86
+
87
+ createEffect(() => {
88
+ activePersonaId();
89
+ resetHistoryIndex();
90
+ });
91
+
92
+ useKeyboard((event: KeyEvent) => {
93
+ if (!suggestVisible()) return;
94
+
95
+ if (event.name === "up") {
96
+ event.preventDefault();
97
+ setSuggestIndex(i => Math.max(0, i - 1));
98
+ return;
99
+ }
100
+ if (event.name === "down") {
101
+ event.preventDefault();
102
+ setSuggestIndex(i => Math.min(suggestMatches().length - 1, i + 1));
103
+ return;
104
+ }
105
+ if (event.name === "tab" || event.name === "right") {
106
+ event.preventDefault();
107
+ const match = suggestMatches()[suggestIndex()];
108
+ if (match) {
109
+ textareaRef?.setText(`/${match.name} `);
110
+ setInputText(`/${match.name} `);
111
+ textareaRef?.gotoBufferEnd();
112
+ setSuggestIndex(0);
113
+ }
114
+ return;
115
+ }
116
+ if (event.name === "escape") {
117
+ event.preventDefault();
118
+ textareaRef?.clear();
119
+ setInputText("");
120
+ setSuggestIndex(0);
121
+ return;
122
+ }
123
+ });
124
+
57
125
  const getCommandContext = (): CommandContext => ({
58
126
  showOverlay,
59
127
  hideOverlay,
@@ -62,21 +130,24 @@ export function PromptInput() {
62
130
  stopProcessor,
63
131
  ei,
64
132
  renderer,
65
- setInputText: (text: string) => textareaRef?.setText(text),
133
+ setInputText: (text: string) => {
134
+ textareaRef?.setText(text);
135
+ setInputText(text);
136
+ },
66
137
  getInputText: () => textareaRef?.plainText || "",
67
138
  });
68
139
 
69
140
  const handleSubmit = async () => {
70
141
  const text = textareaRef?.plainText?.trim();
71
142
  if (!text) return;
72
-
143
+
73
144
  if (text.startsWith("/")) {
74
145
  const isEditorCmd = text.startsWith("/editor") ||
75
146
  text.startsWith("/edit") ||
76
147
  text.startsWith("/e ") ||
77
148
  text === "/e";
78
- const opensEditorForData = text.startsWith("/me") ||
79
- text.startsWith("/details") ||
149
+ const opensEditorForData = text.startsWith("/me") ||
150
+ text.startsWith("/details") ||
80
151
  text.startsWith("/d ") ||
81
152
  text === "/d" ||
82
153
  text.startsWith("/settings") ||
@@ -86,19 +157,27 @@ export function PromptInput() {
86
157
  text.startsWith("/quotes") ||
87
158
  text.startsWith("/q ") ||
88
159
  text.startsWith("/context") ||
89
- text.startsWith("/messages");
90
-
160
+ text.startsWith("/messages") ||
161
+ text === "/queue" ||
162
+ text === "/dlq";
163
+
91
164
  if (!isEditorCmd && !opensEditorForData) {
92
165
  textareaRef?.clear();
166
+ setInputText("");
93
167
  }
94
168
  await parseAndExecute(text, getCommandContext());
95
169
  if (opensEditorForData) {
96
170
  textareaRef?.clear();
171
+ setInputText("");
97
172
  }
173
+ setSuggestIndex(0);
98
174
  return;
99
175
  }
100
-
176
+
101
177
  textareaRef?.clear();
178
+ setInputText("");
179
+ resetHistoryIndex();
180
+ setSuggestIndex(0);
102
181
  if (!activePersonaId()) return;
103
182
  await sendMessage(text);
104
183
  };
@@ -115,31 +194,40 @@ export function PromptInput() {
115
194
  };
116
195
 
117
196
  return (
118
- <box
197
+ <box
198
+ flexDirection="column"
119
199
  flexShrink={0}
120
- border={["top"]}
121
- borderStyle="single"
122
- backgroundColor="#0f3460"
123
- paddingLeft={1}
124
- paddingRight={1}
125
- paddingTop={0.5}
126
- paddingBottom={0.5}
127
200
  >
128
- <textarea
129
- ref={(r: TextareaRenderable) => {
130
- textareaRef = r;
131
- registerTextarea(r);
132
- }}
133
- focused={!overlayRenderer()}
134
- onSubmit={() => void handleSubmit()}
135
- placeholder={getPlaceholder()}
136
- textColor="#eee8d5"
137
- backgroundColor="#0f3460"
138
- cursorColor="#eee8d5"
139
- minHeight={1}
140
- maxHeight={6}
141
- keyBindings={overlayRenderer() ? [] : TEXTAREA_KEYBINDINGS}
201
+ <CommandSuggest
202
+ input={inputText}
203
+ highlightIndex={suggestIndex}
142
204
  />
205
+ <box
206
+ border={["top"]}
207
+ borderStyle="single"
208
+ backgroundColor="#0f3460"
209
+ paddingLeft={1}
210
+ paddingRight={1}
211
+ paddingTop={0.5}
212
+ paddingBottom={0.5}
213
+ >
214
+ <textarea
215
+ ref={(r: TextareaRenderable) => {
216
+ textareaRef = r;
217
+ registerTextarea(r);
218
+ }}
219
+ focused={!overlayRenderer()}
220
+ onSubmit={() => void handleSubmit()}
221
+ onContentChange={() => setInputText(textareaRef?.plainText ?? "")}
222
+ placeholder={getPlaceholder()}
223
+ textColor="#eee8d5"
224
+ backgroundColor="#0f3460"
225
+ cursorColor="#eee8d5"
226
+ minHeight={1}
227
+ maxHeight={6}
228
+ keyBindings={overlayRenderer() ? [] : TEXTAREA_KEYBINDINGS}
229
+ />
230
+ </box>
143
231
  </box>
144
232
  );
145
233
  }