@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.
- package/dist/bot/assistant-core.d.ts +2 -0
- package/dist/bot/assistant-core.d.ts.map +1 -1
- package/dist/bot/assistant-core.js +130 -17
- package/dist/bot/assistant-core.js.map +1 -1
- package/dist/bot/conversation-store.d.ts +1 -0
- package/dist/bot/conversation-store.d.ts.map +1 -1
- package/dist/bot/conversation-store.js +32 -0
- package/dist/bot/conversation-store.js.map +1 -1
- package/dist/bot/response-formatter.d.ts +15 -0
- package/dist/bot/response-formatter.d.ts.map +1 -0
- package/dist/bot/response-formatter.js +40 -0
- package/dist/bot/response-formatter.js.map +1 -0
- package/dist/bot/rich-input.d.ts +39 -0
- package/dist/bot/rich-input.d.ts.map +1 -0
- package/dist/bot/rich-input.js +308 -0
- package/dist/bot/rich-input.js.map +1 -0
- package/dist/bot/slash-commands.d.ts +20 -0
- package/dist/bot/slash-commands.d.ts.map +1 -0
- package/dist/bot/slash-commands.js +93 -0
- package/dist/bot/slash-commands.js.map +1 -0
- package/dist/cli-handlers.d.ts +1 -0
- package/dist/cli-handlers.d.ts.map +1 -1
- package/dist/cli-handlers.js +103 -2
- package/dist/cli-handlers.js.map +1 -1
- package/dist/node-types/agent-execute.js +15 -3
- package/dist/node-types/agent-execute.js.map +1 -1
- package/flowweaver.manifest.json +1 -1
- package/package.json +1 -1
- package/src/bot/assistant-core.ts +131 -19
- package/src/bot/conversation-store.ts +32 -0
- package/src/bot/response-formatter.ts +42 -0
- package/src/bot/rich-input.ts +307 -0
- package/src/bot/slash-commands.ts +114 -0
- package/src/cli-handlers.ts +105 -3
- 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
|
|
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
|
-
|
|
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(`
|
|
178
|
+
out(` ${c.dim(`New conversation`)}\n`);
|
|
126
179
|
}
|
|
127
|
-
out(` ${c.dim('Type your request. Ctrl+C to exit.')}\n\n`);
|
|
128
|
-
|
|
129
|
-
//
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
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> =>
|
|
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 (
|
|
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
|
-
|
|
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
|
+
}
|