@synergenius/flow-weaver-pack-weaver 0.9.4 → 0.9.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.
Files changed (35) hide show
  1. package/dist/bot/assistant-core.d.ts +2 -0
  2. package/dist/bot/assistant-core.d.ts.map +1 -1
  3. package/dist/bot/assistant-core.js +130 -17
  4. package/dist/bot/assistant-core.js.map +1 -1
  5. package/dist/bot/conversation-store.d.ts +1 -0
  6. package/dist/bot/conversation-store.d.ts.map +1 -1
  7. package/dist/bot/conversation-store.js +32 -0
  8. package/dist/bot/conversation-store.js.map +1 -1
  9. package/dist/bot/response-formatter.d.ts +15 -0
  10. package/dist/bot/response-formatter.d.ts.map +1 -0
  11. package/dist/bot/response-formatter.js +40 -0
  12. package/dist/bot/response-formatter.js.map +1 -0
  13. package/dist/bot/rich-input.d.ts +39 -0
  14. package/dist/bot/rich-input.d.ts.map +1 -0
  15. package/dist/bot/rich-input.js +308 -0
  16. package/dist/bot/rich-input.js.map +1 -0
  17. package/dist/bot/slash-commands.d.ts +20 -0
  18. package/dist/bot/slash-commands.d.ts.map +1 -0
  19. package/dist/bot/slash-commands.js +93 -0
  20. package/dist/bot/slash-commands.js.map +1 -0
  21. package/dist/cli-handlers.d.ts +1 -0
  22. package/dist/cli-handlers.d.ts.map +1 -1
  23. package/dist/cli-handlers.js +103 -2
  24. package/dist/cli-handlers.js.map +1 -1
  25. package/dist/node-types/agent-execute.js +15 -3
  26. package/dist/node-types/agent-execute.js.map +1 -1
  27. package/flowweaver.manifest.json +1 -1
  28. package/package.json +1 -1
  29. package/src/bot/assistant-core.ts +131 -19
  30. package/src/bot/conversation-store.ts +32 -0
  31. package/src/bot/response-formatter.ts +42 -0
  32. package/src/bot/rich-input.ts +307 -0
  33. package/src/bot/slash-commands.ts +114 -0
  34. package/src/cli-handlers.ts +105 -3
  35. package/src/node-types/agent-execute.ts +17 -4
@@ -8,7 +8,8 @@
8
8
  * - API provider: tools collected and executed manually (tool_use_start/end events)
9
9
  */
10
10
 
11
- import * as readline from 'node:readline';
11
+ import * as path from 'node:path';
12
+ import * as os from 'node:os';
12
13
  import {
13
14
  runAgentLoop,
14
15
  type AgentProvider,
@@ -30,6 +31,8 @@ export interface AssistantOptions {
30
31
  systemPrompt?: string;
31
32
  /** Override for testing — provide messages instead of reading stdin */
32
33
  inputMessages?: string[];
34
+ /** Watch a directory for file changes and auto-suggest fixes */
35
+ watchDir?: string;
33
36
  /** Resume a specific conversation by ID */
34
37
  resumeId?: string;
35
38
  /** Always start a fresh conversation */
@@ -70,6 +73,41 @@ For those: you may summarize or explain the result as needed.`;
70
73
 
71
74
  export async function runAssistant(opts: AssistantOptions): Promise<void> {
72
75
  const { provider, tools, executor, projectDir } = opts;
76
+ const out = (s: string) => process.stderr.write(s);
77
+
78
+ // Pipe mode: if stdin is not a TTY, read all input as one message
79
+ if (!process.stdin.isTTY && !opts.inputMessages) {
80
+ const chunks: string[] = [];
81
+ for await (const chunk of process.stdin) {
82
+ chunks.push(typeof chunk === 'string' ? chunk : chunk.toString());
83
+ }
84
+ const pipeInput = chunks.join('').trim();
85
+ if (!pipeInput) return;
86
+
87
+ // Build system prompt for pipe mode
88
+ let systemPrompt = opts.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
89
+ try {
90
+ const fsMod = await import('node:fs');
91
+ const pathMod = await import('node:path');
92
+ const planPath = pathMod.resolve(projectDir, '.weaver-plan.md');
93
+ if (fsMod.existsSync(planPath)) {
94
+ const plan = fsMod.readFileSync(planPath, 'utf-8').trim();
95
+ systemPrompt += '\n\n## Project Plan & Vision\n\nAll bots you spawn and tasks you queue MUST align with this plan.\n\n' + plan;
96
+ }
97
+ } catch { /* plan not available */ }
98
+
99
+ // Run single message, print result, exit
100
+ await runAgentLoop(provider, tools, executor, [{ role: 'user', content: pipeInput }], {
101
+ systemPrompt, maxIterations: 20,
102
+ onStreamEvent: (e) => { if (e.type === 'text_delta') out(e.text); },
103
+ onToolEvent: (e) => {
104
+ if (e.type === 'tool_call_start') out(`\n ${c.cyan('◆')} ${e.name}\n`);
105
+ if (e.type === 'tool_call_result') out(` ${c.dim('→')} ${(e.result ?? '').slice(0, 200)}\n`);
106
+ },
107
+ });
108
+ out('\n');
109
+ return;
110
+ }
73
111
 
74
112
  // Build system prompt — include project plan if it exists
75
113
  let systemPrompt = opts.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
@@ -83,8 +121,6 @@ export async function runAssistant(opts: AssistantOptions): Promise<void> {
83
121
  }
84
122
  } catch { /* plan not available */ }
85
123
 
86
- const out = (s: string) => process.stderr.write(s);
87
-
88
124
  // Persistent conversation store
89
125
  const { ConversationStore } = await import('./conversation-store.js');
90
126
  const store = new ConversationStore();
@@ -116,31 +152,53 @@ export async function runAssistant(opts: AssistantOptions): Promise<void> {
116
152
  }
117
153
  }
118
154
 
155
+ // Resolve versions
156
+ let fwVersion = '?';
157
+ let weaverVersion = '?';
158
+ try {
159
+ const { execFileSync: vExec } = await import('node:child_process');
160
+ fwVersion = vExec('npx', ['flow-weaver', '--version'], { encoding: 'utf-8', cwd: projectDir, timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim().replace(/^flow-weaver\s+v?/i, '');
161
+ } catch { /* not available */ }
162
+ try {
163
+ const fsMod = await import('node:fs');
164
+ const url = await import('node:url');
165
+ const packPkg = JSON.parse(fsMod.readFileSync(new url.URL('../package.json', import.meta.url), 'utf-8'));
166
+ weaverVersion = packPkg.version;
167
+ } catch { /* not available */ }
168
+
119
169
  // Welcome
120
- out(`\n ${c.bold('weaver assistant')}\n`);
121
- out(` ${c.dim(`Project: ${projectDir}`)}\n`);
170
+ out(`\n ${c.bold('weaver assistant')} ${c.dim(`v${weaverVersion}`)} ${c.dim(`· flow-weaver v${fwVersion}`)}\n`);
171
+ if (process.env.FW_PLATFORM_TOKEN) {
172
+ out(` ${c.dim('AI: Platform credits (no API key needed)')}\n`);
173
+ }
174
+ out(` ${c.dim(`Project: ${path.basename(projectDir)}`)}\n`);
122
175
  if (conversation.title) {
123
176
  out(` ${c.dim(`Resuming: "${conversation.title}" (${conversation.messageCount} messages)`)}\n`);
124
177
  } else {
125
- out(` ${c.dim(`Conversation: ${conversation.id}`)}\n`);
178
+ out(` ${c.dim(`New conversation`)}\n`);
126
179
  }
127
- out(` ${c.dim('Type your request. Ctrl+C to exit.')}\n\n`);
128
-
129
- // Input source
130
- const rl = opts.inputMessages
131
- ? null
132
- : readline.createInterface({ input: process.stdin, output: process.stderr, prompt: `${c.cyan('')} ` });
180
+ out(` ${c.dim('Type your request. Ctrl+C to exit. /help for commands.')}\n\n`);
181
+
182
+ // Rich input with history, arrows, tab completion, slash commands
183
+ const { RichInput } = await import('./rich-input.js');
184
+ const { getSlashCompletions, handleSlashCommand } = await import('./slash-commands.js');
185
+ const { formatResponse } = await import('./response-formatter.js');
186
+
187
+ const richInput = opts.inputMessages ? null : new RichInput({
188
+ historyFile: path.join(os.homedir(), '.weaver', 'input-history.txt'),
189
+ prompt: `${c.cyan('❯')} `,
190
+ completionProvider: (partial) => {
191
+ if (partial.startsWith('/')) return getSlashCompletions(partial);
192
+ return [];
193
+ },
194
+ });
133
195
 
134
196
  const getNextInput = opts.inputMessages
135
197
  ? (() => {
136
198
  let i = 0;
137
199
  return (): Promise<string | null> => Promise.resolve(opts.inputMessages![i++] ?? null);
138
200
  })()
139
- : (): Promise<string | null> => new Promise<string | null>((resolve) => {
140
- rl!.prompt();
141
- rl!.once('line', (line) => resolve(line.trim() || null));
142
- rl!.once('close', () => resolve(null));
143
- });
201
+ : (): Promise<string | null> => richInput!.getInput();
144
202
 
145
203
  const onStreamEvent = (event: StreamEvent) => {
146
204
  if (event.type === 'text_delta') {
@@ -169,14 +227,64 @@ export async function runAssistant(opts: AssistantOptions): Promise<void> {
169
227
  }
170
228
  };
171
229
 
230
+ // Slash command context
231
+ let shouldExit = false;
232
+ const slashCtx = {
233
+ executor,
234
+ out,
235
+ projectDir,
236
+ conversationId: conversation.id,
237
+ onClear: () => { history.length = 0; },
238
+ onExit: () => { shouldExit = true; },
239
+ onNew: () => { history.length = 0; conversation = store.create(projectDir); },
240
+ onVerbose: () => { out(` ${c.dim('Verbose toggling not yet wired to streaming.')}\n`); },
241
+ };
242
+
243
+ // Watch mode: monitor directory for file changes, auto-validate
244
+ let watcher: import('node:fs').FSWatcher | null = null;
245
+ if (opts.watchDir) {
246
+ try {
247
+ const fsMod = await import('node:fs');
248
+ const { execFileSync } = await import('node:child_process');
249
+ watcher = fsMod.watch(opts.watchDir, { recursive: true }, (_event, filename) => {
250
+ if (!filename || !filename.endsWith('.ts')) return;
251
+ const filePath = `${opts.watchDir}/${filename}`;
252
+ try {
253
+ const result = execFileSync('npx', ['flow-weaver', 'validate', filePath, '--json'], {
254
+ encoding: 'utf-8', cwd: projectDir, timeout: 15_000, stdio: ['pipe', 'pipe', 'pipe'],
255
+ });
256
+ const parsed = JSON.parse(result);
257
+ const errorCount = parsed.errorCount ?? parsed.errors?.length ?? 0;
258
+ if (errorCount > 0) {
259
+ out(`\n ${c.yellow('⚠')} ${filename} changed: ${errorCount} validation error(s)\n`);
260
+ out(` ${c.dim('Type a message to fix, or ignore.')}\n`);
261
+ }
262
+ } catch { /* validation failed or not a workflow — ignore */ }
263
+ });
264
+ out(` ${c.dim(`Watching: ${opts.watchDir}`)}\n\n`);
265
+ } catch { /* watch not available */ }
266
+ }
267
+
172
268
  // Main conversation loop
173
- while (true) {
269
+ while (!shouldExit) {
174
270
  const input = await getNextInput();
175
271
  if (input === null) break;
176
272
  if (!input.trim()) continue;
177
273
 
274
+ // Handle slash commands
275
+ if (input.startsWith('/')) {
276
+ const handled = await handleSlashCommand(input, slashCtx);
277
+ if (handled) continue;
278
+ // Unknown slash command — tell user
279
+ out(` ${c.dim('Unknown command. Type /help for available commands.')}\n\n`);
280
+ continue;
281
+ }
282
+
178
283
  out('\n');
179
284
 
285
+ // Reset Ctrl+C counter after successful input
286
+ richInput?.resetCtrlC();
287
+
180
288
  // Add user message to history
181
289
  history.push({ role: 'user', content: input });
182
290
 
@@ -208,6 +316,9 @@ export async function runAssistant(opts: AssistantOptions): Promise<void> {
208
316
  store.appendMessages(conversation.id, [{ role: 'user', content: input }, ...newMessages]);
209
317
  store.updateAfterTurn(conversation.id, [{ role: 'user', content: input }, ...newMessages], tokensUsed);
210
318
 
319
+ // Sync to cloud if logged in (fire-and-forget)
320
+ store.syncToCloud(conversation.id, [{ role: 'user', content: input }, ...newMessages]).catch(() => {});
321
+
211
322
  // Auto-title from first assistant response
212
323
  if (!conversation.title) {
213
324
  const firstAssistant = newMessages.find(m => m.role === 'assistant');
@@ -234,7 +345,8 @@ export async function runAssistant(opts: AssistantOptions): Promise<void> {
234
345
  out('\n');
235
346
  }
236
347
 
237
- rl?.close();
348
+ watcher?.close();
349
+ richInput?.destroy();
238
350
  out(`\n ${c.dim('Goodbye.')}\n\n`);
239
351
  }
240
352
 
@@ -176,6 +176,38 @@ export class ConversationStore {
176
176
  });
177
177
  }
178
178
 
179
+ async syncToCloud(id: string, newMessages: AgentMessage[]): Promise<void> {
180
+ try {
181
+ const credPath = path.join(os.homedir(), '.fw', 'credentials.json');
182
+ if (!fs.existsSync(credPath)) return;
183
+ const creds = JSON.parse(fs.readFileSync(credPath, 'utf-8'));
184
+ if (!creds.token || !creds.platformUrl || creds.expiresAt <= Date.now()) return;
185
+
186
+ const conversation = this.get(id);
187
+ if (!conversation) return;
188
+
189
+ // Fire-and-forget sync — don't block the conversation
190
+ const lastMessage = newMessages.find(m => m.role === 'user');
191
+ if (!lastMessage) return;
192
+
193
+ const message = typeof lastMessage.content === 'string' ? lastMessage.content : JSON.stringify(lastMessage.content);
194
+
195
+ fetch(`${creds.platformUrl}/ai-chat/stream`, {
196
+ method: 'POST',
197
+ headers: {
198
+ 'Content-Type': 'application/json',
199
+ ...(creds.token.startsWith('fw_')
200
+ ? { 'X-API-Key': creds.token }
201
+ : { Authorization: `Bearer ${creds.token}` }),
202
+ },
203
+ body: JSON.stringify({
204
+ message: `[Synced from CLI] ${message}`,
205
+ conversationId: (conversation as any).cloudConversationId,
206
+ }),
207
+ }).catch(() => {}); // fire-and-forget
208
+ } catch { /* sync not available */ }
209
+ }
210
+
179
211
  async setTitle(id: string, title: string): Promise<void> {
180
212
  await withFileLock(this.indexPath, () => {
181
213
  const index = this.readIndex();
@@ -0,0 +1,42 @@
1
+ import * as path from 'node:path';
2
+ import { c } from './ansi.js';
3
+
4
+ /**
5
+ * Highlight code blocks in streamed text.
6
+ * Detects ```lang ... ``` patterns and applies dim styling.
7
+ */
8
+ export function highlightCodeBlocks(text: string): string {
9
+ return text.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => {
10
+ const header = lang ? ` ${c.cyan(`[${lang}]`)}\n` : '';
11
+ return `${header}${c.dim(code)}`;
12
+ });
13
+ }
14
+
15
+ /**
16
+ * Make file paths clickable using OSC 8 terminal hyperlinks.
17
+ * Only active when terminal supports it.
18
+ */
19
+ export function linkifyPaths(text: string, cwd: string): string {
20
+ if (!supportsHyperlinks()) return text;
21
+ return text.replace(/\b((?:src|tests|lib|dist)\/[\w/.-]+\.(?:ts|js|json|md))\b/g, (match) => {
22
+ const abs = path.resolve(cwd, match);
23
+ return `\x1b]8;;file://${abs}\x07${match}\x1b]8;;\x07`;
24
+ });
25
+ }
26
+
27
+ /**
28
+ * Format a full response — apply all formatting passes.
29
+ */
30
+ export function formatResponse(text: string, cwd: string): string {
31
+ let result = text;
32
+ result = highlightCodeBlocks(result);
33
+ result = linkifyPaths(result, cwd);
34
+ return result;
35
+ }
36
+
37
+ function supportsHyperlinks(): boolean {
38
+ const term = process.env.TERM_PROGRAM ?? '';
39
+ // Known terminals that support OSC 8
40
+ return ['iTerm.app', 'WezTerm', 'vscode', 'Hyper'].includes(term)
41
+ || !!process.env.TERM_PROGRAM_VERSION; // Most modern terminals
42
+ }
@@ -0,0 +1,307 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ import * as readline from 'node:readline';
5
+
6
+ export interface RichInputOptions {
7
+ historyFile?: string;
8
+ prompt?: string;
9
+ completionProvider?: (partial: string) => string[];
10
+ maxHistorySize?: number;
11
+ }
12
+
13
+ export class RichInput {
14
+ private history: string[] = [];
15
+ private historyIndex = -1;
16
+ private currentLine = '';
17
+ private cursorPos = 0;
18
+ private multiLineBuffer: string[] = [];
19
+ private searchMode = false;
20
+ private searchQuery = '';
21
+ private ctrlCCount = 0;
22
+ private prompt: string;
23
+ private historyFile: string;
24
+ private completionProvider?: (partial: string) => string[];
25
+ private maxHistory: number;
26
+
27
+ constructor(opts: RichInputOptions = {}) {
28
+ this.prompt = opts.prompt ?? '❯ ';
29
+ this.historyFile = opts.historyFile ?? path.join(os.homedir(), '.weaver', 'input-history.txt');
30
+ this.completionProvider = opts.completionProvider;
31
+ this.maxHistory = opts.maxHistorySize ?? 500;
32
+ this.loadHistory();
33
+ }
34
+
35
+ async getInput(): Promise<string | null> {
36
+ // Non-TTY fallback
37
+ if (!process.stdin.isTTY) {
38
+ return this.getInputReadline();
39
+ }
40
+
41
+ return new Promise((resolve) => {
42
+ this.ctrlCCount = 0;
43
+ this.historyIndex = -1;
44
+ this.currentLine = '';
45
+ this.cursorPos = 0;
46
+ this.searchMode = false;
47
+
48
+ process.stdin.setRawMode(true);
49
+ process.stdin.resume();
50
+
51
+ const handler = (key: Buffer) => {
52
+ this.handleKey(key, (result) => {
53
+ process.stdin.setRawMode(false);
54
+ process.stdin.pause();
55
+ process.stdin.removeListener('data', handler);
56
+ resolve(result);
57
+ });
58
+ };
59
+
60
+ process.stdin.on('data', handler);
61
+ this.renderPrompt();
62
+ });
63
+ }
64
+
65
+ resetCtrlC(): void {
66
+ this.ctrlCCount = 0;
67
+ }
68
+
69
+ private handleKey(key: Buffer, resolve: (value: string | null) => void): void {
70
+ const s = key.toString();
71
+
72
+ // Handle search mode separately
73
+ if (this.searchMode) {
74
+ this.handleSearchKey(s, resolve);
75
+ return;
76
+ }
77
+
78
+ if (s === '\r' || s === '\n') {
79
+ this.handleEnter(resolve);
80
+ } else if (s === '\x03') { // Ctrl+C
81
+ this.ctrlCCount++;
82
+ if (this.ctrlCCount >= 2 || this.currentLine === '') {
83
+ process.stderr.write('\n');
84
+ resolve(null);
85
+ } else {
86
+ this.currentLine = '';
87
+ this.cursorPos = 0;
88
+ process.stderr.write('\n');
89
+ this.renderPrompt();
90
+ }
91
+ } else if (s === '\x0c') { // Ctrl+L
92
+ process.stderr.write('\x1b[2J\x1b[H'); // clear screen + move to top
93
+ this.renderPrompt();
94
+ } else if (s === '\x12') { // Ctrl+R
95
+ this.searchMode = true;
96
+ this.searchQuery = '';
97
+ this.renderSearchPrompt();
98
+ } else if (s === '\x09') { // Tab
99
+ this.handleTab();
100
+ } else if (s === '\x1b[A') { // Arrow Up
101
+ this.historyUp();
102
+ } else if (s === '\x1b[B') { // Arrow Down
103
+ this.historyDown();
104
+ } else if (s === '\x1b[C') { // Arrow Right
105
+ if (this.cursorPos < this.currentLine.length) {
106
+ this.cursorPos++;
107
+ process.stderr.write('\x1b[C');
108
+ }
109
+ } else if (s === '\x1b[D') { // Arrow Left
110
+ if (this.cursorPos > 0) {
111
+ this.cursorPos--;
112
+ process.stderr.write('\x1b[D');
113
+ }
114
+ } else if (s === '\x7f' || s === '\b') { // Backspace
115
+ if (this.cursorPos > 0) {
116
+ this.currentLine = this.currentLine.slice(0, this.cursorPos - 1) + this.currentLine.slice(this.cursorPos);
117
+ this.cursorPos--;
118
+ this.renderPrompt();
119
+ }
120
+ } else if (s === '\x1b[3~') { // Delete
121
+ if (this.cursorPos < this.currentLine.length) {
122
+ this.currentLine = this.currentLine.slice(0, this.cursorPos) + this.currentLine.slice(this.cursorPos + 1);
123
+ this.renderPrompt();
124
+ }
125
+ } else if (s === '\x01') { // Ctrl+A (home)
126
+ this.cursorPos = 0;
127
+ this.renderPrompt();
128
+ } else if (s === '\x05') { // Ctrl+E (end)
129
+ this.cursorPos = this.currentLine.length;
130
+ this.renderPrompt();
131
+ } else if (s === '\x15') { // Ctrl+U (clear line)
132
+ this.currentLine = '';
133
+ this.cursorPos = 0;
134
+ this.renderPrompt();
135
+ } else if (s >= ' ' && s.length === 1) { // Printable
136
+ this.ctrlCCount = 0;
137
+ this.currentLine = this.currentLine.slice(0, this.cursorPos) + s + this.currentLine.slice(this.cursorPos);
138
+ this.cursorPos++;
139
+ this.renderPrompt();
140
+ } else if (s.length > 1 && !s.startsWith('\x1b')) {
141
+ // Pasted text (multiple chars at once)
142
+ this.ctrlCCount = 0;
143
+ this.currentLine = this.currentLine.slice(0, this.cursorPos) + s + this.currentLine.slice(this.cursorPos);
144
+ this.cursorPos += s.length;
145
+ this.renderPrompt();
146
+ }
147
+ }
148
+
149
+ private handleEnter(resolve: (value: string | null) => void): void {
150
+ const fullLine = this.multiLineBuffer.length > 0
151
+ ? [...this.multiLineBuffer, this.currentLine].join('\n')
152
+ : this.currentLine;
153
+
154
+ // Check for multi-line continuation
155
+ if (this.isIncomplete(fullLine)) {
156
+ this.multiLineBuffer.push(this.currentLine);
157
+ this.currentLine = '';
158
+ this.cursorPos = 0;
159
+ process.stderr.write('\n');
160
+ process.stderr.write(' ... ');
161
+ return;
162
+ }
163
+
164
+ process.stderr.write('\n');
165
+
166
+ const trimmed = fullLine.trim();
167
+ if (trimmed) {
168
+ this.addToHistory(trimmed);
169
+ }
170
+
171
+ this.multiLineBuffer = [];
172
+ this.currentLine = '';
173
+ this.cursorPos = 0;
174
+
175
+ resolve(trimmed || null);
176
+ }
177
+
178
+ private isIncomplete(text: string): boolean {
179
+ const backticks = (text.match(/```/g) || []).length;
180
+ if (backticks % 2 !== 0) return true;
181
+ if (text.endsWith('\\')) return true;
182
+ return false;
183
+ }
184
+
185
+ private historyUp(): void {
186
+ if (this.history.length === 0) return;
187
+ if (this.historyIndex < this.history.length - 1) {
188
+ this.historyIndex++;
189
+ this.currentLine = this.history[this.history.length - 1 - this.historyIndex];
190
+ this.cursorPos = this.currentLine.length;
191
+ this.renderPrompt();
192
+ }
193
+ }
194
+
195
+ private historyDown(): void {
196
+ if (this.historyIndex > 0) {
197
+ this.historyIndex--;
198
+ this.currentLine = this.history[this.history.length - 1 - this.historyIndex];
199
+ this.cursorPos = this.currentLine.length;
200
+ this.renderPrompt();
201
+ } else if (this.historyIndex === 0) {
202
+ this.historyIndex = -1;
203
+ this.currentLine = '';
204
+ this.cursorPos = 0;
205
+ this.renderPrompt();
206
+ }
207
+ }
208
+
209
+ private handleTab(): void {
210
+ if (!this.completionProvider) return;
211
+ const candidates = this.completionProvider(this.currentLine);
212
+ if (candidates.length === 0) return;
213
+ if (candidates.length === 1) {
214
+ this.currentLine = candidates[0];
215
+ this.cursorPos = this.currentLine.length;
216
+ this.renderPrompt();
217
+ } else {
218
+ // Show candidates below, then redraw prompt
219
+ process.stderr.write('\n ' + candidates.join(' ') + '\n');
220
+ this.renderPrompt();
221
+ }
222
+ }
223
+
224
+ private handleSearchKey(s: string, resolve: (value: string | null) => void): void {
225
+ if (s === '\x1b' || s === '\x03') { // Esc or Ctrl+C — cancel search
226
+ this.searchMode = false;
227
+ this.renderPrompt();
228
+ } else if (s === '\r' || s === '\n') { // Enter — accept match
229
+ this.searchMode = false;
230
+ const match = this.searchHistory(this.searchQuery);
231
+ if (match) {
232
+ this.currentLine = match;
233
+ this.cursorPos = this.currentLine.length;
234
+ }
235
+ this.renderPrompt();
236
+ } else if (s === '\x7f' || s === '\b') { // Backspace
237
+ this.searchQuery = this.searchQuery.slice(0, -1);
238
+ this.renderSearchPrompt();
239
+ } else if (s >= ' ' && s.length === 1) {
240
+ this.searchQuery += s;
241
+ this.renderSearchPrompt();
242
+ }
243
+ }
244
+
245
+ private searchHistory(query: string): string | null {
246
+ if (!query) return null;
247
+ const lower = query.toLowerCase();
248
+ for (let i = this.history.length - 1; i >= 0; i--) {
249
+ if (this.history[i].toLowerCase().includes(lower)) return this.history[i];
250
+ }
251
+ return null;
252
+ }
253
+
254
+ private renderPrompt(): void {
255
+ process.stderr.write(`\r\x1b[K${this.prompt}${this.currentLine}`);
256
+ // Move cursor to correct position
257
+ const offset = this.currentLine.length - this.cursorPos;
258
+ if (offset > 0) process.stderr.write(`\x1b[${offset}D`);
259
+ }
260
+
261
+ private renderSearchPrompt(): void {
262
+ const match = this.searchHistory(this.searchQuery) ?? '';
263
+ process.stderr.write(`\r\x1b[K\x1b[2m(search): ${this.searchQuery}\x1b[0m ${match}`);
264
+ }
265
+
266
+ private addToHistory(line: string): void {
267
+ // Don't add duplicates of the last entry
268
+ if (this.history.length > 0 && this.history[this.history.length - 1] === line) return;
269
+ this.history.push(line);
270
+ if (this.history.length > this.maxHistory) this.history.shift();
271
+ this.saveHistory();
272
+ }
273
+
274
+ private loadHistory(): void {
275
+ try {
276
+ if (fs.existsSync(this.historyFile)) {
277
+ this.history = fs.readFileSync(this.historyFile, 'utf-8')
278
+ .split('\n')
279
+ .filter(Boolean)
280
+ .slice(-this.maxHistory);
281
+ }
282
+ } catch { /* history not available */ }
283
+ }
284
+
285
+ private saveHistory(): void {
286
+ try {
287
+ const dir = path.dirname(this.historyFile);
288
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
289
+ fs.writeFileSync(this.historyFile, this.history.join('\n') + '\n');
290
+ } catch { /* non-fatal */ }
291
+ }
292
+
293
+ private async getInputReadline(): Promise<string | null> {
294
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr, prompt: this.prompt });
295
+ return new Promise<string | null>((resolve) => {
296
+ rl.prompt();
297
+ rl.once('line', (line) => { rl.close(); resolve(line.trim() || null); });
298
+ rl.once('close', () => resolve(null));
299
+ });
300
+ }
301
+
302
+ destroy(): void {
303
+ if (process.stdin.isTTY) {
304
+ try { process.stdin.setRawMode(false); } catch {}
305
+ }
306
+ }
307
+ }