pelulu-cli 1.0.0 → 1.0.1
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/DailyNotesApp/app/build.gradle.kts +57 -0
- package/DailyNotesApp/app/src/main/java/com/dailynotes/app/MainActivity.kt +48 -0
- package/DailyNotesApp/app/src/main/java/com/dailynotes/app/Note.kt +13 -0
- package/DailyNotesApp/app/src/main/java/com/dailynotes/app/NoteDatabase.kt +9 -0
- package/DailyNotesApp/build.gradle.kts +5 -0
- package/DailyNotesApp/settings.gradle.kts +17 -0
- package/README.md +69 -82
- package/config.example.json +2 -1
- package/package.json +6 -3
- package/preview.jpg +0 -0
- package/src/core/auto-format.js +61 -12
- package/src/core/config.js +1 -1
- package/src/core/confirm.js +2 -2
- package/src/core/diff.js +4 -4
- package/src/core/doctor.js +2 -2
- package/src/core/error-handler.js +1 -1
- package/src/core/file-tracker.js +4 -4
- package/src/core/formatter.js +19 -19
- package/src/core/logger.js +19 -3
- package/src/core/spinner.js +2 -2
- package/src/core/stats.js +2 -2
- package/src/core/thinking.js +8 -8
- package/src/core/tool-help.js +2 -2
- package/src/core/tool-registry.js +6 -10
- package/src/core/update-checker.js +125 -0
- package/src/core/wizard.js +5 -5
- package/src/core/workspace.js +4 -4
- package/src/index.js +82 -37
- package/src/mcp/activation.js +3 -3
- package/src/mcp/message-sender.js +1 -1
- package/src/mcp/mqtt-client.js +4 -4
- package/src/repl-commands.js +2 -1
- package/src/repl.js +26 -6
- package/src/tools/config.js +1 -1
- package/src/tools/diff.js +6 -0
- package/src/tools/file.js +3 -3
- package/src/tools/git.js +1 -1
- package/src/tools/network.js +1 -1
- package/src/tools/project.js +2 -2
- package/src/tools/search.js +3 -3
- package/src/tools/shell.js +2 -2
- package/src/tools/template.js +1 -1
- package/src/tools/watch.js +1 -1
- package/src/tui/completable-input.js +127 -0
- package/src/tui/ink-app.js +324 -0
- package/src/tui/ink-components.js +157 -0
- package/src/tui/ink-entry.js +86 -0
- package/src/tui/renderer.js +137 -19
- package/src/tui/status-bar.js +7 -7
package/src/mcp/mqtt-client.js
CHANGED
|
@@ -111,9 +111,9 @@ export class MqttClient {
|
|
|
111
111
|
for (const r of responses) {
|
|
112
112
|
if (r.type === 'mcp') { this._send(r); }
|
|
113
113
|
else if (r.type === 'tool_call') {
|
|
114
|
-
log('tool',
|
|
114
|
+
log('tool', r.name);
|
|
115
115
|
this.mcp.executeTool(r.name, r.args)
|
|
116
|
-
.then(result => { log('tool', result.isError ? '
|
|
116
|
+
.then(result => { log('tool', result.isError ? 'failed' : 'done'); this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result } }); })
|
|
117
117
|
.catch(err => { this._send({ type: 'mcp', payload: { jsonrpc: '2.0', id: r.id, result: { content: [{ type: 'text', text: err.message }], isError: true } } }); });
|
|
118
118
|
}
|
|
119
119
|
}
|
|
@@ -124,7 +124,7 @@ export class MqttClient {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
_onOther(msg) {
|
|
127
|
-
if (msg.type === 'stt') { log('user',
|
|
127
|
+
if (msg.type === 'stt') { log('user', `"${msg.text}"`); bus.emit('stt', msg.text); }
|
|
128
128
|
if (msg.type === 'llm' && msg.text) { bus.emit('llm:text', msg.text); }
|
|
129
129
|
if (msg.type === 'tts' && msg.state === 'sentence_start' && msg.text) { bus.emit('tts:sentence', msg.text); }
|
|
130
130
|
if (msg.type === 'goodbye') { this.sessionId = null; this.mcp.reset(); this._helloQueue = []; }
|
|
@@ -138,7 +138,7 @@ export class MqttClient {
|
|
|
138
138
|
|
|
139
139
|
sendText(text) {
|
|
140
140
|
this._send({ type: 'listen', state: 'detect', text });
|
|
141
|
-
log('user',
|
|
141
|
+
log('user', `"${text}"`);
|
|
142
142
|
bus.emit('user:text', text);
|
|
143
143
|
}
|
|
144
144
|
|
package/src/repl-commands.js
CHANGED
|
@@ -76,7 +76,7 @@ export async function handleCommand(cmd, arg, ctx) {
|
|
|
76
76
|
case '/history': {
|
|
77
77
|
const recent = history.slice(-10);
|
|
78
78
|
if (!recent.length) { log('info', 'No history'); break; }
|
|
79
|
-
console.log(`\n${COLORS.bold}
|
|
79
|
+
console.log(`\n${COLORS.bold}[LOG] Recent:${COLORS.reset}\n`);
|
|
80
80
|
for (const h of recent) console.log(` ${COLORS.dim}${new Date(h.ts).toLocaleTimeString()}${COLORS.reset} ${h.text.slice(0, 80)}`);
|
|
81
81
|
console.log();
|
|
82
82
|
break;
|
|
@@ -84,6 +84,7 @@ export async function handleCommand(cmd, arg, ctx) {
|
|
|
84
84
|
|
|
85
85
|
case '/doctor': await runDoctor(); break;
|
|
86
86
|
case '/model': console.log(`\n${formatModelInfo()}\n`); break;
|
|
87
|
+
case '/keys': return 'keys';
|
|
87
88
|
case '/clear': console.clear(); break;
|
|
88
89
|
default: log('info', `Unknown: ${cmd}. /help`);
|
|
89
90
|
}
|
package/src/repl.js
CHANGED
|
@@ -9,6 +9,8 @@ import { formatToolResult } from './core/formatter.js';
|
|
|
9
9
|
import { parseIntent } from './core/intent.js';
|
|
10
10
|
import { FileTracker } from './core/file-tracker.js';
|
|
11
11
|
import { handleCommand } from './repl-commands.js';
|
|
12
|
+
import { getCompletions } from './core/completion.js';
|
|
13
|
+
import { formatKeybindings } from './core/keybindings.js';
|
|
12
14
|
import {
|
|
13
15
|
renderBanner, renderStatus, renderTools, renderHelp,
|
|
14
16
|
renderToolCall, renderToolResult, renderAiResponse,
|
|
@@ -16,19 +18,21 @@ import {
|
|
|
16
18
|
} from './tui/renderer.js';
|
|
17
19
|
|
|
18
20
|
export class REPL {
|
|
19
|
-
constructor(registry, mqtt, stats, session) {
|
|
21
|
+
constructor(registry, mqtt, stats, session, extras = {}) {
|
|
20
22
|
this.registry = registry;
|
|
21
23
|
this.mqtt = mqtt;
|
|
22
24
|
this.stats = stats;
|
|
23
25
|
this.session = session;
|
|
24
26
|
this.fileTracker = new FileTracker();
|
|
27
|
+
this.thinking = extras.thinking || null;
|
|
28
|
+
this.sender = extras.sender || null;
|
|
29
|
+
this.autoFormat = extras.autoFormat || null;
|
|
25
30
|
this.rl = null;
|
|
26
31
|
this.history = [];
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
start() {
|
|
30
35
|
const config = getConfig();
|
|
31
|
-
renderBanner(config, this.registry.all(), this.mqtt.connected);
|
|
32
36
|
this._events();
|
|
33
37
|
|
|
34
38
|
if (!process.stdin.isTTY) {
|
|
@@ -41,6 +45,10 @@ export class REPL {
|
|
|
41
45
|
this.rl = createInterface({
|
|
42
46
|
input: process.stdin, output: process.stdout,
|
|
43
47
|
prompt: createPrompt(dirName), historySize: 200,
|
|
48
|
+
completer: (line) => {
|
|
49
|
+
const hits = getCompletions(line);
|
|
50
|
+
return [hits.length ? hits : [], line];
|
|
51
|
+
},
|
|
44
52
|
});
|
|
45
53
|
this.rl.prompt();
|
|
46
54
|
this.rl.on('line', (input) => this._line(input.trim()));
|
|
@@ -49,12 +57,15 @@ export class REPL {
|
|
|
49
57
|
|
|
50
58
|
_events() {
|
|
51
59
|
bus.on('llm:text', (text) => {
|
|
60
|
+
if (this.thinking) this.thinking.set('idle');
|
|
61
|
+
// Clear any thinking indicator line
|
|
62
|
+
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
52
63
|
renderAiResponse(text);
|
|
53
64
|
if (this.rl) this.rl.prompt();
|
|
54
65
|
});
|
|
55
66
|
|
|
56
67
|
bus.on('tts:sentence', (text) => {
|
|
57
|
-
|
|
68
|
+
// TTS is audio output, no need to display text
|
|
58
69
|
});
|
|
59
70
|
|
|
60
71
|
bus.on('tool:called', ({ name, result, args }) => {
|
|
@@ -63,7 +74,7 @@ export class REPL {
|
|
|
63
74
|
});
|
|
64
75
|
|
|
65
76
|
bus.on('ready', () => {
|
|
66
|
-
|
|
77
|
+
if (this.rl) this.rl.prompt();
|
|
67
78
|
});
|
|
68
79
|
}
|
|
69
80
|
|
|
@@ -75,7 +86,11 @@ export class REPL {
|
|
|
75
86
|
const intent = parseIntent(input);
|
|
76
87
|
if (intent.matched) {
|
|
77
88
|
renderUserInput(input);
|
|
89
|
+
if (this.thinking) this.thinking.set('tool_call');
|
|
78
90
|
const result = await this.registry.call(intent.tool, intent.params);
|
|
91
|
+
if (this.thinking) this.thinking.set('idle');
|
|
92
|
+
// Clear thinking indicator
|
|
93
|
+
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
79
94
|
const formatted = formatToolResult(intent.tool, intent.action, result);
|
|
80
95
|
console.log(`\n${formatted}\n`);
|
|
81
96
|
this.history.push({ role: 'user', text: input, ts: Date.now() });
|
|
@@ -86,6 +101,7 @@ export class REPL {
|
|
|
86
101
|
// Send to XiaoZhi
|
|
87
102
|
renderUserInput(input);
|
|
88
103
|
this.history.push({ role: 'user', text: input, ts: Date.now() });
|
|
104
|
+
if (this.thinking) this.thinking.set('thinking');
|
|
89
105
|
this.mqtt.sendText(input);
|
|
90
106
|
this.rl?.prompt();
|
|
91
107
|
}
|
|
@@ -93,15 +109,19 @@ export class REPL {
|
|
|
93
109
|
async _cmd(input) {
|
|
94
110
|
const [cmd, ...rest] = input.split(' ');
|
|
95
111
|
const arg = rest.join(' ');
|
|
96
|
-
const ctx = {
|
|
112
|
+
const ctx = {
|
|
113
|
+
registry: this.registry, mqtt: this.mqtt, stats: this.stats,
|
|
114
|
+
session: this.session, fileTracker: this.fileTracker, history: this.history,
|
|
115
|
+
};
|
|
97
116
|
const special = await handleCommand(cmd, arg, ctx);
|
|
98
117
|
|
|
99
118
|
if (special === 'help') renderHelp();
|
|
100
119
|
if (special === 'tools') renderTools(this.registry.list());
|
|
120
|
+
if (special === 'keys') console.log(formatKeybindings());
|
|
101
121
|
if (special === 'status') {
|
|
102
122
|
const s = this.session.getStats();
|
|
103
123
|
renderStatus({
|
|
104
|
-
'MQTT': this.mqtt.connected ? '
|
|
124
|
+
'MQTT': this.mqtt.connected ? '[OK] Connected' : '[ERR] Disconnected',
|
|
105
125
|
'Session': this.mqtt.sessionId || 'none',
|
|
106
126
|
'Tools': `${this.registry.all().length} (${this.registry.all().reduce((s, t) => s + (t.actions?.length || 0), 0)} actions)`,
|
|
107
127
|
'Turns': s.turns,
|
package/src/tools/config.js
CHANGED
|
@@ -36,7 +36,7 @@ const ACTIONS = {
|
|
|
36
36
|
const config = getConfig();
|
|
37
37
|
setNested(config, key, value);
|
|
38
38
|
await saveConfig(config._root, config);
|
|
39
|
-
log('config',
|
|
39
|
+
log('config', `[CFG] Set ${key} = ${value}`);
|
|
40
40
|
return { key, value, saved: true };
|
|
41
41
|
},
|
|
42
42
|
},
|
package/src/tools/diff.js
CHANGED
|
@@ -6,6 +6,7 @@ import { readFile } from 'fs/promises';
|
|
|
6
6
|
import { resolve } from 'path';
|
|
7
7
|
import { homedir } from 'os';
|
|
8
8
|
import { log } from '../core/logger.js';
|
|
9
|
+
import { showDiff } from '../core/diff.js';
|
|
9
10
|
|
|
10
11
|
const HOME = homedir();
|
|
11
12
|
|
|
@@ -50,6 +51,11 @@ const ACTIONS = {
|
|
|
50
51
|
|
|
51
52
|
return { file1: abs1, file2: abs2, totalChanges: changes.length, hunks: hunks.slice(0, 20) };
|
|
52
53
|
},
|
|
54
|
+
format: async ({ file1, file2 }) => {
|
|
55
|
+
const c1 = await readFile(file1, 'utf-8').catch(() => '');
|
|
56
|
+
const c2 = await readFile(file2, 'utf-8').catch(() => '');
|
|
57
|
+
if (c1 && c2) showDiff(c1, c2, file1);
|
|
58
|
+
},
|
|
53
59
|
},
|
|
54
60
|
|
|
55
61
|
stats: {
|
package/src/tools/file.js
CHANGED
|
@@ -40,7 +40,7 @@ const ACTIONS = {
|
|
|
40
40
|
const abs = safe(path);
|
|
41
41
|
await mkdir(dirname(abs), { recursive: true });
|
|
42
42
|
await writeFile(abs, content, 'utf-8');
|
|
43
|
-
log('file',
|
|
43
|
+
log('file', `[EDIT] Written: ${abs} (${content.length} chars)`);
|
|
44
44
|
return { path: abs, written: content.length };
|
|
45
45
|
},
|
|
46
46
|
},
|
|
@@ -54,7 +54,7 @@ const ACTIONS = {
|
|
|
54
54
|
const count = content.split(old_text).length - 1;
|
|
55
55
|
content = content.replace(old_text, new_text);
|
|
56
56
|
await writeFile(abs, content, 'utf-8');
|
|
57
|
-
log('file',
|
|
57
|
+
log('file', `[EDIT] Edited: ${abs} (${count} occurrence(s))`);
|
|
58
58
|
return { path: abs, edited: true, occurrences: count };
|
|
59
59
|
},
|
|
60
60
|
},
|
|
@@ -80,7 +80,7 @@ const ACTIONS = {
|
|
|
80
80
|
const abs = safe(path);
|
|
81
81
|
if (!existsSync(abs)) throw new Error(`Not found: ${abs}`);
|
|
82
82
|
await unlink(abs);
|
|
83
|
-
log('file',
|
|
83
|
+
log('file', `[DEL] Deleted: ${abs}`);
|
|
84
84
|
return { path: abs, deleted: true };
|
|
85
85
|
},
|
|
86
86
|
},
|
package/src/tools/git.js
CHANGED
|
@@ -85,7 +85,7 @@ const ACTIONS = {
|
|
|
85
85
|
const msg = params.message.replace(/"/g, '\\"');
|
|
86
86
|
const r = await git(`commit -m "${msg}"`, dir);
|
|
87
87
|
if (r.code !== 0 && r.stderr.includes('nothing to commit')) return { committed: false, reason: 'nothing to commit' };
|
|
88
|
-
log('git',
|
|
88
|
+
log('git', `[EDIT] Committed: ${params.message}`);
|
|
89
89
|
return { committed: true, message: params.message, output: r.stdout.slice(-300) };
|
|
90
90
|
},
|
|
91
91
|
},
|
package/src/tools/network.js
CHANGED
|
@@ -25,7 +25,7 @@ function httpGet(url, maxChars = 5000) {
|
|
|
25
25
|
const ACTIONS = {
|
|
26
26
|
async fetch({ url, method, body, headers }) {
|
|
27
27
|
if (!url) throw new Error('url required');
|
|
28
|
-
log('network',
|
|
28
|
+
log('network', `[WEB] ${method || 'GET'} ${url}`);
|
|
29
29
|
const result = await httpGet(url);
|
|
30
30
|
return { url, status: result.status, body: result.body.slice(0, 5000) };
|
|
31
31
|
},
|
package/src/tools/project.js
CHANGED
|
@@ -57,7 +57,7 @@ const ACTIONS = {
|
|
|
57
57
|
const type = await detectProject(dir);
|
|
58
58
|
const cmd = BUILD_CMD[type];
|
|
59
59
|
if (!cmd) throw new Error(`No build for ${type}`);
|
|
60
|
-
log('project',
|
|
60
|
+
log('project', `[BUILD] Building (${type})...`);
|
|
61
61
|
const r = await run(cmd, dir);
|
|
62
62
|
return { success: r.code === 0, type, exitCode: r.code, output: r.stdout.slice(-500) || r.stderr.slice(-500) };
|
|
63
63
|
},
|
|
@@ -70,7 +70,7 @@ const ACTIONS = {
|
|
|
70
70
|
const type = await detectProject(dir);
|
|
71
71
|
const cmd = TEST_CMD[type];
|
|
72
72
|
if (!cmd) throw new Error(`No test for ${type}`);
|
|
73
|
-
log('project',
|
|
73
|
+
log('project', `[TEST] Testing (${type})...`);
|
|
74
74
|
const r = await run(cmd, dir);
|
|
75
75
|
return { passed: r.code === 0, type, exitCode: r.code, output: r.stdout.slice(-1000) || r.stderr.slice(-1000) };
|
|
76
76
|
},
|
package/src/tools/search.js
CHANGED
|
@@ -34,7 +34,7 @@ const ACTIONS = {
|
|
|
34
34
|
required: ['pattern'],
|
|
35
35
|
handler: async ({ pattern, path, ignoreCase, include, context }) => {
|
|
36
36
|
if (!pattern) throw new Error('pattern required');
|
|
37
|
-
log('search',
|
|
37
|
+
log('search', `[FIND] grep: ${pattern}`);
|
|
38
38
|
const flags = ignoreCase ? '-rn' : '-rn';
|
|
39
39
|
const ic = ignoreCase ? 'i' : '';
|
|
40
40
|
const inc = include ? `--include="*.${include}"` : '';
|
|
@@ -49,7 +49,7 @@ const ACTIONS = {
|
|
|
49
49
|
find: {
|
|
50
50
|
required: ['name'],
|
|
51
51
|
handler: async ({ name, path, type }) => {
|
|
52
|
-
log('search',
|
|
52
|
+
log('search', `[FIND] find: ${name}`);
|
|
53
53
|
const t = type ? `-type ${type}` : '';
|
|
54
54
|
const cmd = `find "${path || '.'}" ${t} -name "${name}" 2>/dev/null | head -50`;
|
|
55
55
|
const r = await runCmd(cmd);
|
|
@@ -60,7 +60,7 @@ const ACTIONS = {
|
|
|
60
60
|
web: {
|
|
61
61
|
required: ['url'],
|
|
62
62
|
handler: async ({ url, maxChars }) => {
|
|
63
|
-
log('search',
|
|
63
|
+
log('search', `[WEB] fetch: ${url}`);
|
|
64
64
|
const r = await httpGet(url, maxChars || 8000);
|
|
65
65
|
return { url, status: r.status, body: r.body.slice(0, maxChars || 8000), length: r.body.length };
|
|
66
66
|
},
|
package/src/tools/shell.js
CHANGED
|
@@ -26,7 +26,7 @@ const ACTIONS = {
|
|
|
26
26
|
exec: {
|
|
27
27
|
required: ['command'],
|
|
28
28
|
handler: async ({ command, timeout }) => {
|
|
29
|
-
log('shell',
|
|
29
|
+
log('shell', `[CFG] $ ${command}`);
|
|
30
30
|
const result = await run(command, timeout);
|
|
31
31
|
const maxLen = getConfig().tools?.max_output || 10000;
|
|
32
32
|
return { stdout: result.stdout.slice(0, maxLen), stderr: result.stderr.slice(0, 2000), exitCode: result.code };
|
|
@@ -36,7 +36,7 @@ const ACTIONS = {
|
|
|
36
36
|
bg: {
|
|
37
37
|
required: ['command'],
|
|
38
38
|
handler: async ({ command }) => {
|
|
39
|
-
log('shell',
|
|
39
|
+
log('shell', `[CFG] [bg] $ ${command}`);
|
|
40
40
|
const child = spawn('bash', ['-c', command], { detached: true, stdio: 'ignore' });
|
|
41
41
|
child.unref();
|
|
42
42
|
return { pid: child.pid, background: true, command };
|
package/src/tools/template.js
CHANGED
|
@@ -71,7 +71,7 @@ const ACTIONS = {
|
|
|
71
71
|
for (const [file, content] of Object.entries(files)) {
|
|
72
72
|
await writeFile(join(dir, file), content);
|
|
73
73
|
}
|
|
74
|
-
log('template',
|
|
74
|
+
log('template', `[DIR] Created ${template}: ${dir}`);
|
|
75
75
|
return { created: true, template, path: dir, files: Object.keys(files) };
|
|
76
76
|
},
|
|
77
77
|
},
|
package/src/tools/watch.js
CHANGED
|
@@ -26,7 +26,7 @@ const ACTIONS = {
|
|
|
26
26
|
const watcher = fsWatch(abs, { recursive: recursive !== false }, (eventType, filename) => {
|
|
27
27
|
if (filter && !filename.includes(filter)) return;
|
|
28
28
|
const event = { type: eventType, file: filename, path: abs, ts: Date.now() };
|
|
29
|
-
log('watch',
|
|
29
|
+
log('watch', `[DIR] ${eventType}: ${filename}`);
|
|
30
30
|
bus.emit('file:change', event);
|
|
31
31
|
});
|
|
32
32
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CompletableInput — TextInput with Tab auto-completion
|
|
3
|
+
* Wraps ink-text-input and adds completion support
|
|
4
|
+
*/
|
|
5
|
+
import React, { useState, useCallback } from 'react';
|
|
6
|
+
import { Box, Text, useInput } from 'ink';
|
|
7
|
+
import TextInput from 'ink-text-input';
|
|
8
|
+
import { getCompletions } from '../core/completion.js';
|
|
9
|
+
|
|
10
|
+
export function CompletableInput({ onSubmit, placeholder }) {
|
|
11
|
+
const [value, setValue] = useState('');
|
|
12
|
+
const [completions, setCompletions] = useState([]);
|
|
13
|
+
const [completionIndex, setCompletionIndex] = useState(0);
|
|
14
|
+
const [showCompletions, setShowCompletions] = useState(false);
|
|
15
|
+
|
|
16
|
+
// Handle Tab for completion
|
|
17
|
+
useInput((input, key) => {
|
|
18
|
+
if (key.tab) {
|
|
19
|
+
if (!showCompletions) {
|
|
20
|
+
// First Tab: show completions
|
|
21
|
+
const hits = getCompletions(value);
|
|
22
|
+
if (hits.length > 0) {
|
|
23
|
+
setCompletions(hits);
|
|
24
|
+
setCompletionIndex(0);
|
|
25
|
+
setShowCompletions(true);
|
|
26
|
+
// Auto-fill first match
|
|
27
|
+
if (hits.length === 1) {
|
|
28
|
+
setValue(hits[0] + ' ');
|
|
29
|
+
setShowCompletions(false);
|
|
30
|
+
} else {
|
|
31
|
+
setValue(hits[0]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
// Subsequent Tab: cycle through completions
|
|
36
|
+
const next = (completionIndex + 1) % completions.length;
|
|
37
|
+
setCompletionIndex(next);
|
|
38
|
+
setValue(completions[next]);
|
|
39
|
+
}
|
|
40
|
+
} else if (key.escape) {
|
|
41
|
+
// Escape: hide completions
|
|
42
|
+
setShowCompletions(false);
|
|
43
|
+
setCompletions([]);
|
|
44
|
+
setCompletionIndex(0);
|
|
45
|
+
} else if (key.return) {
|
|
46
|
+
// Enter: handled by TextInput onSubmit, do nothing here
|
|
47
|
+
setShowCompletions(false);
|
|
48
|
+
setCompletions([]);
|
|
49
|
+
} else {
|
|
50
|
+
// Any other key: reset completions
|
|
51
|
+
if (showCompletions) {
|
|
52
|
+
setShowCompletions(false);
|
|
53
|
+
setCompletions([]);
|
|
54
|
+
setCompletionIndex(0);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Update value from TextInput (but not on Tab/Enter which are handled above)
|
|
60
|
+
const handleChange = useCallback((val) => {
|
|
61
|
+
setValue(val);
|
|
62
|
+
// Live completion hints
|
|
63
|
+
if (val.length > 0) {
|
|
64
|
+
const hits = getCompletions(val);
|
|
65
|
+
if (hits.length > 0 && hits[0] !== val) {
|
|
66
|
+
setCompletions(hits);
|
|
67
|
+
setShowCompletions(true);
|
|
68
|
+
setCompletionIndex(0);
|
|
69
|
+
} else {
|
|
70
|
+
setShowCompletions(false);
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
setShowCompletions(false);
|
|
74
|
+
}
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
// Handle TextInput submit (Enter key)
|
|
78
|
+
const handleSubmit = useCallback((val) => {
|
|
79
|
+
const trimmed = val.trim();
|
|
80
|
+
if (trimmed) {
|
|
81
|
+
onSubmit(trimmed);
|
|
82
|
+
setValue('');
|
|
83
|
+
}
|
|
84
|
+
setShowCompletions(false);
|
|
85
|
+
setCompletions([]);
|
|
86
|
+
}, [onSubmit]);
|
|
87
|
+
|
|
88
|
+
return React.createElement(Box, { flexDirection: 'column', width: '100%' },
|
|
89
|
+
// Completion suggestions
|
|
90
|
+
showCompletions && completions.length > 1
|
|
91
|
+
? React.createElement(Box, {
|
|
92
|
+
paddingLeft: 2, paddingBottom: 0,
|
|
93
|
+
flexDirection: 'row', flexWrap: 'wrap', gap: 1,
|
|
94
|
+
},
|
|
95
|
+
completions.slice(0, 8).map((c, i) =>
|
|
96
|
+
React.createElement(Text, {
|
|
97
|
+
key: c,
|
|
98
|
+
color: i === completionIndex ? 'cyan' : 'dim',
|
|
99
|
+
bold: i === completionIndex,
|
|
100
|
+
dimColor: i !== completionIndex,
|
|
101
|
+
},
|
|
102
|
+
i === completionIndex ? `[${c}]` : ` ${c} `
|
|
103
|
+
)
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
: null,
|
|
107
|
+
|
|
108
|
+
// Input line
|
|
109
|
+
React.createElement(Box, { paddingX: 1 },
|
|
110
|
+
React.createElement(Text, { color: 'cyan' }, '> '),
|
|
111
|
+
React.createElement(TextInput, {
|
|
112
|
+
value,
|
|
113
|
+
onChange: handleChange,
|
|
114
|
+
onSubmit: handleSubmit,
|
|
115
|
+
placeholder: placeholder || 'type a message...',
|
|
116
|
+
// Don't show cursor when we have a completion suggestion
|
|
117
|
+
showCursor: !showCompletions || completions.length <= 1,
|
|
118
|
+
}),
|
|
119
|
+
// Show ghost completion (the remaining part of the suggested completion)
|
|
120
|
+
showCompletions && completions.length > 0 && value.length > 0
|
|
121
|
+
? React.createElement(Text, { dimColor: true },
|
|
122
|
+
completions[completionIndex]?.slice(value.length) || ''
|
|
123
|
+
)
|
|
124
|
+
: null,
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
}
|