icopilot 2.2.1 → 2.3.2
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/README.md +324 -166
- package/dist/commands/suggest-cmd.js +148 -8
- package/dist/index.js +22 -1
- package/dist/modes/interactive.js +4 -1
- package/dist/modes/tui.js +22 -6
- package/dist/tools/shell.js +133 -25
- package/dist/ui/box.js +79 -0
- package/dist/ui/prompt.js +174 -12
- package/dist/ui/render.js +32 -3
- package/dist/ui/theme.js +71 -13
- package/package.json +2 -2
|
@@ -1,20 +1,57 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { select, input } from '@inquirer/prompts';
|
|
1
3
|
import { streamChat } from '../api/github-models.js';
|
|
2
4
|
import { theme } from '../ui/theme.js';
|
|
5
|
+
import { box, commandChip } from '../ui/box.js';
|
|
6
|
+
import { copyTextToClipboard } from './clipboard-cmd.js';
|
|
3
7
|
const SUGGEST_SYSTEM_PROMPT = `You translate natural-language requests into exactly one shell command.
|
|
4
8
|
Respond with ONLY the command text.
|
|
5
9
|
Do not explain anything.
|
|
6
10
|
Do not use markdown fences.
|
|
7
11
|
Do not add bullets, labels, or commentary.
|
|
8
12
|
Prefer a safe, direct command that can run in the user's current working directory.`;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
const REVISE_SYSTEM_PROMPT = `You are refining a shell command based on user feedback.
|
|
14
|
+
Respond with ONLY the revised command text.
|
|
15
|
+
Do not explain anything.
|
|
16
|
+
Do not use markdown fences.`;
|
|
17
|
+
function detectShell() {
|
|
18
|
+
const shellEnv = process.env.SHELL ?? '';
|
|
19
|
+
if (shellEnv.includes('zsh'))
|
|
20
|
+
return 'zsh';
|
|
21
|
+
if (shellEnv.includes('fish'))
|
|
22
|
+
return 'fish';
|
|
23
|
+
if (shellEnv.includes('bash'))
|
|
24
|
+
return 'bash';
|
|
25
|
+
if (process.platform === 'win32')
|
|
26
|
+
return 'powershell';
|
|
27
|
+
return 'bash';
|
|
28
|
+
}
|
|
29
|
+
async function pickShell() {
|
|
30
|
+
const detected = detectShell();
|
|
31
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
32
|
+
return detected;
|
|
33
|
+
}
|
|
34
|
+
const allShells = [
|
|
35
|
+
{ name: `${detected} (detected)`, value: detected },
|
|
36
|
+
{ name: 'bash', value: 'bash' },
|
|
37
|
+
{ name: 'zsh', value: 'zsh' },
|
|
38
|
+
{ name: 'fish', value: 'fish' },
|
|
39
|
+
{ name: 'powershell', value: 'powershell' },
|
|
40
|
+
{ name: 'cmd', value: 'cmd' },
|
|
41
|
+
];
|
|
42
|
+
return select({
|
|
43
|
+
message: 'What shell are you targeting?',
|
|
44
|
+
choices: allShells.filter((c, i) => i === 0 || c.value !== detected),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async function generateCommand(query, shell, session, signal, priorCommand, revision) {
|
|
13
48
|
const messages = [
|
|
14
|
-
{ role: 'system', content: SUGGEST_SYSTEM_PROMPT },
|
|
49
|
+
{ role: 'system', content: revision ? REVISE_SYSTEM_PROMPT : SUGGEST_SYSTEM_PROMPT },
|
|
15
50
|
{
|
|
16
51
|
role: 'user',
|
|
17
|
-
content:
|
|
52
|
+
content: revision
|
|
53
|
+
? `Original request: ${query}\nOriginal command: ${priorCommand}\nFeedback: ${revision}\nTarget shell: ${shell}\nCWD: ${session.state.cwd}`
|
|
54
|
+
: `Target shell: ${shell}\nCurrent working directory: ${session.state.cwd}\nRequest: ${query}`,
|
|
18
55
|
},
|
|
19
56
|
];
|
|
20
57
|
let suggestion = '';
|
|
@@ -27,8 +64,111 @@ export async function suggestCommand(query, session, signal) {
|
|
|
27
64
|
suggestion += token;
|
|
28
65
|
},
|
|
29
66
|
});
|
|
30
|
-
|
|
31
|
-
|
|
67
|
+
return sanitizeSuggestion(result.content || suggestion);
|
|
68
|
+
}
|
|
69
|
+
async function explainCommand(command, session, signal) {
|
|
70
|
+
const messages = [
|
|
71
|
+
{
|
|
72
|
+
role: 'system',
|
|
73
|
+
content: 'Explain the shell command clearly: 1) one-sentence summary, 2) breakdown of each part, 3) any risks. Be concise.',
|
|
74
|
+
},
|
|
75
|
+
{ role: 'user', content: `Explain: ${command}` },
|
|
76
|
+
];
|
|
77
|
+
process.stdout.write(box('', { title: 'Explanation', style: 'response' }).slice(0, -1) + '\n');
|
|
78
|
+
let explanation = '';
|
|
79
|
+
await streamChat({
|
|
80
|
+
model: session.state.model,
|
|
81
|
+
messages,
|
|
82
|
+
temperature: 0.2,
|
|
83
|
+
signal,
|
|
84
|
+
onToken: (token) => {
|
|
85
|
+
explanation += token;
|
|
86
|
+
process.stdout.write(token);
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
process.stdout.write('\n');
|
|
90
|
+
}
|
|
91
|
+
async function executeCommand(command) {
|
|
92
|
+
process.stdout.write(theme.dim(`\nRunning: ${command}\n\n`));
|
|
93
|
+
try {
|
|
94
|
+
execSync(command, { stdio: 'inherit', cwd: process.cwd() });
|
|
95
|
+
process.stdout.write(theme.ok('\n✔ Command completed\n'));
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
process.stdout.write(theme.err(`\n✖ Command failed: ${err?.message ?? String(err)}\n`));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export async function suggestCommand(query, session, signal) {
|
|
102
|
+
const trimmedQuery = query.trim();
|
|
103
|
+
if (!trimmedQuery)
|
|
104
|
+
return theme.warn('usage: /suggest <request>\n');
|
|
105
|
+
const shell = await pickShell();
|
|
106
|
+
process.stdout.write(theme.dim(`\nGenerating ${shell} command…\n`));
|
|
107
|
+
let command = await generateCommand(trimmedQuery, shell, session, signal);
|
|
108
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
109
|
+
return box(commandChip(command), { title: 'Suggested command', style: 'command' });
|
|
110
|
+
}
|
|
111
|
+
// Post-suggestion action loop — mirrors GitHub Copilot CLI's interactive UX
|
|
112
|
+
let running = true;
|
|
113
|
+
while (running) {
|
|
114
|
+
process.stdout.write('\n');
|
|
115
|
+
process.stdout.write(box(commandChip(command), { title: 'Suggested command', style: 'command' }));
|
|
116
|
+
let action;
|
|
117
|
+
try {
|
|
118
|
+
action = await select({
|
|
119
|
+
message: 'What would you like to do?',
|
|
120
|
+
choices: [
|
|
121
|
+
{ name: 'Execute this command', value: 'execute' },
|
|
122
|
+
{ name: 'Copy command to clipboard', value: 'copy' },
|
|
123
|
+
{ name: 'Explain this command', value: 'explain' },
|
|
124
|
+
{ name: 'Revise this command', value: 'revise' },
|
|
125
|
+
{ name: 'Exit', value: 'exit' },
|
|
126
|
+
],
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// user Ctrl-C'd the menu
|
|
131
|
+
running = false;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (action === 'execute') {
|
|
135
|
+
await executeCommand(command);
|
|
136
|
+
running = false;
|
|
137
|
+
}
|
|
138
|
+
else if (action === 'copy') {
|
|
139
|
+
try {
|
|
140
|
+
await copyTextToClipboard(command);
|
|
141
|
+
process.stdout.write(theme.ok('✔ Command copied to clipboard\n'));
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
process.stdout.write(theme.err(`✖ Copy failed: ${err?.message ?? String(err)}\n`));
|
|
145
|
+
}
|
|
146
|
+
running = false;
|
|
147
|
+
}
|
|
148
|
+
else if (action === 'explain') {
|
|
149
|
+
await explainCommand(command, session, signal);
|
|
150
|
+
// continue loop so user can still execute/copy
|
|
151
|
+
}
|
|
152
|
+
else if (action === 'revise') {
|
|
153
|
+
let feedback;
|
|
154
|
+
try {
|
|
155
|
+
feedback = await input({ message: 'What should be different?' });
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
running = false;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (feedback.trim()) {
|
|
162
|
+
process.stdout.write(theme.dim('\nRefining command…\n'));
|
|
163
|
+
command = await generateCommand(trimmedQuery, shell, session, signal, command, feedback);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
// exit
|
|
168
|
+
running = false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return '';
|
|
32
172
|
}
|
|
33
173
|
function sanitizeSuggestion(content) {
|
|
34
174
|
const withoutFences = content
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import './util/perf.js';
|
|
2
2
|
import { enablePerfTrace, markFirstPrompt } from './util/perf.js';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { Command } from 'commander';
|
|
5
6
|
import { runInteractive } from './modes/interactive.js';
|
|
@@ -17,6 +18,11 @@ import { openBrowser } from './util/browser.js';
|
|
|
17
18
|
function friendlyError(err) {
|
|
18
19
|
const message = String(err?.message || err);
|
|
19
20
|
const status = err?.status ?? err?.response?.status;
|
|
21
|
+
// No token configured at all — catch this before any network error
|
|
22
|
+
if (!config.token && config.provider === 'github') {
|
|
23
|
+
return ('Authentication is not configured for provider "github".\n' +
|
|
24
|
+
' Set GITHUB_TOKEN, set ICOPILOT_TOKEN, or sign in with `gh auth login`.');
|
|
25
|
+
}
|
|
20
26
|
if (/GITHUB_TOKEN|ICOPILOT_TOKEN|OPENAI_API_KEY|ANTHROPIC_API_KEY/i.test(message)) {
|
|
21
27
|
if (config.provider === 'github') {
|
|
22
28
|
return ('Authentication is not configured for provider "github".\n' +
|
|
@@ -183,12 +189,27 @@ export async function run(opts) {
|
|
|
183
189
|
});
|
|
184
190
|
}
|
|
185
191
|
export function createProgram() {
|
|
192
|
+
const _require = createRequire(import.meta.url);
|
|
193
|
+
// dist/index.js → ../../package.json won't work; resolve from CWD or use __dirname equivalent
|
|
194
|
+
let pkgVersion = '0.0.0';
|
|
195
|
+
try {
|
|
196
|
+
const pkgPath = new URL('../package.json', import.meta.url).pathname;
|
|
197
|
+
pkgVersion = _require(pkgPath).version;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
try {
|
|
201
|
+
pkgVersion = _require('../../package.json').version;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
/* fallback */
|
|
205
|
+
}
|
|
206
|
+
}
|
|
186
207
|
const invokedAs = path.basename(process.argv[1] ?? 'icopilot').replace(/\.js$/, '');
|
|
187
208
|
const cliName = ['icopilot', 'icli'].includes(invokedAs) ? invokedAs : 'icopilot';
|
|
188
209
|
const program = new Command()
|
|
189
210
|
.name(cliName)
|
|
190
211
|
.description('iCopilot — terminal-native agentic CLI powered by GitHub Models')
|
|
191
|
-
.version(
|
|
212
|
+
.version(pkgVersion)
|
|
192
213
|
.option('-p, --prompt <text>', 'one-shot mode: run a single prompt and exit')
|
|
193
214
|
.option('-m, --model <name>', 'model id (default: gpt-4o-mini)')
|
|
194
215
|
.option('--local', 'use the default local OpenAI-compatible provider (ollama)')
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
2
4
|
import { Session } from '../session/session.js';
|
|
3
5
|
import { theme, banner } from '../ui/theme.js';
|
|
4
6
|
import { createPrompt, prefix } from '../ui/prompt.js';
|
|
@@ -35,7 +37,8 @@ export async function runInteractive(initialMode = 'ask', opts = {}) {
|
|
|
35
37
|
// Apply keybinding configuration
|
|
36
38
|
const keybindingMode = applyKeybindingConfig();
|
|
37
39
|
if (!config.quiet) {
|
|
38
|
-
|
|
40
|
+
const sessionDir = config.sessionDir ?? path.join(os.homedir(), '.icopilot', 'sessions');
|
|
41
|
+
process.stdout.write(banner(VERSION, session.state.model, sessionDir));
|
|
39
42
|
if (keybindingMode !== 'default') {
|
|
40
43
|
process.stdout.write(getKeybindingHelp(keybindingMode));
|
|
41
44
|
}
|
package/dist/modes/tui.js
CHANGED
|
@@ -85,20 +85,34 @@ export async function runTui(initialMode = 'ask', opts = {}) {
|
|
|
85
85
|
const chatBottom = Math.max(chatTop, rows - 3);
|
|
86
86
|
const chatHeight = Math.max(1, chatBottom - chatTop + 1);
|
|
87
87
|
writeRaw('\x1b[2J\x1b[H');
|
|
88
|
-
writeRaw(statusLine(session, cols));
|
|
88
|
+
writeRaw(statusLine(session, cols, busy));
|
|
89
89
|
const lines = wrapLines(chat.trimEnd(), Math.max(1, cols));
|
|
90
90
|
const visible = lines.slice(-chatHeight);
|
|
91
91
|
for (let i = 0; i < chatHeight; i++) {
|
|
92
92
|
writeRaw(`\x1b[${chatTop + i};1H`);
|
|
93
93
|
writeRaw(pad(visible[i] || '', cols));
|
|
94
94
|
}
|
|
95
|
+
// separator with thinking indicator
|
|
95
96
|
writeRaw(`\x1b[${Math.max(1, rows - 2)};1H`);
|
|
96
|
-
|
|
97
|
+
if (busy) {
|
|
98
|
+
const thinkLabel = ' ◆ Copilot is thinking… ';
|
|
99
|
+
const sideLen = Math.max(0, Math.floor((cols - thinkLabel.length) / 2));
|
|
100
|
+
writeRaw('─'.repeat(sideLen) +
|
|
101
|
+
thinkLabel +
|
|
102
|
+
'─'.repeat(Math.max(0, cols - sideLen - thinkLabel.length)));
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
writeRaw('─'.repeat(cols));
|
|
106
|
+
}
|
|
97
107
|
writeRaw(`\x1b[${Math.max(1, rows - 1)};1H`);
|
|
98
|
-
const
|
|
108
|
+
const promptIcon = busy ? '\x1b[33m◆\x1b[0m' : '\x1b[32m❯\x1b[0m';
|
|
109
|
+
const prompt = `${promptIcon} ${rl.line || ''}`;
|
|
99
110
|
writeRaw(pad(prompt, cols));
|
|
100
111
|
writeRaw(`\x1b[${rows};1H`);
|
|
101
|
-
|
|
112
|
+
const hint = busy
|
|
113
|
+
? '\x1b[2m Ctrl+C to cancel \x1b[0m'
|
|
114
|
+
: '\x1b[2m Enter to send • /help for commands • /suggest for shell commands \x1b[0m';
|
|
115
|
+
writeRaw(pad(hint, cols));
|
|
102
116
|
};
|
|
103
117
|
const appendCaptured = (chunk) => {
|
|
104
118
|
const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
|
|
@@ -240,9 +254,11 @@ export async function runTui(initialMode = 'ask', opts = {}) {
|
|
|
240
254
|
});
|
|
241
255
|
}
|
|
242
256
|
}
|
|
243
|
-
function statusLine(session, cols) {
|
|
257
|
+
function statusLine(session, cols, busy) {
|
|
244
258
|
const mode = session.state.mode.toUpperCase();
|
|
245
|
-
const
|
|
259
|
+
const modelShort = session.state.model.replace('openai/', '').replace('github/', '');
|
|
260
|
+
const busyBadge = busy ? ' \x1b[33m◆ WORKING\x1b[0;7m' : '';
|
|
261
|
+
const text = ` \x1b[1miCopilot\x1b[0;7m v${VERSION}${busyBadge} │ model: ${modelShort} │ mode: ${mode} │ Ctrl+C to exit `;
|
|
246
262
|
return `\x1b[7m${pad(text, cols)}\x1b[0m`;
|
|
247
263
|
}
|
|
248
264
|
function wrapLines(text, width) {
|
package/dist/tools/shell.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { select, input } from '@inquirer/prompts';
|
|
4
4
|
import { config } from '../config.js';
|
|
5
5
|
import { theme } from '../ui/theme.js';
|
|
6
6
|
import { toolMemory } from './memory.js';
|
|
@@ -8,8 +8,7 @@ import { loadPolicy, shellCommandAllowed } from './policy.js';
|
|
|
8
8
|
import { assertSandbox } from './sandbox.js';
|
|
9
9
|
import { checkCommandSafety } from './safety.js';
|
|
10
10
|
/**
|
|
11
|
-
* Propose a shell command
|
|
12
|
-
* Output is streamed to the terminal AND captured for return.
|
|
11
|
+
* Propose a shell command with an interactive action menu, then run it.
|
|
13
12
|
*/
|
|
14
13
|
export async function proposeAndRun(cmd, opts = {}) {
|
|
15
14
|
const cwd = path.resolve(opts.cwd || config.cwd);
|
|
@@ -27,26 +26,35 @@ export async function proposeAndRun(cmd, opts = {}) {
|
|
|
27
26
|
return { ran: false, exitCode: null, stdout: '', stderr: message };
|
|
28
27
|
}
|
|
29
28
|
if (!config.quiet && !config.jsonOutput) {
|
|
30
|
-
process.stdout.write('\n'
|
|
29
|
+
process.stdout.write('\n');
|
|
30
|
+
process.stdout.write(theme.badge('SHELL') + '\n');
|
|
31
31
|
if (opts.explain)
|
|
32
|
-
process.stdout.write(theme.dim(opts.explain) + '\n');
|
|
33
|
-
process.stdout.write(
|
|
32
|
+
process.stdout.write(theme.dim(' ' + opts.explain) + '\n');
|
|
33
|
+
process.stdout.write('\n');
|
|
34
|
+
process.stdout.write(theme.dim(' ┌─────────────────────────────────────────\n'));
|
|
35
|
+
process.stdout.write(theme.dim(' │ ') + theme.hl('$ ') + syntaxHighlightShell(cmd) + '\n');
|
|
36
|
+
process.stdout.write(theme.dim(' └─────────────────────────────────────────\n'));
|
|
34
37
|
process.stdout.write(theme.dim(` cwd: ${cwd}\n`));
|
|
38
|
+
process.stdout.write('\n');
|
|
35
39
|
}
|
|
36
40
|
const safety = checkCommandSafety(cmd);
|
|
37
41
|
const remembered = toolMemory.isShellRemembered(cmd);
|
|
42
|
+
let activeCmd = cmd;
|
|
38
43
|
let ok = false;
|
|
39
44
|
if (config.autoApprove && safety.level !== 'critical') {
|
|
40
45
|
ok = true;
|
|
41
46
|
}
|
|
42
47
|
else if (safety.level === 'critical') {
|
|
43
|
-
ok = await
|
|
48
|
+
ok = await approveCritical(safety.reason).catch(() => false);
|
|
44
49
|
}
|
|
45
50
|
else if (remembered && safety.level === 'safe') {
|
|
46
51
|
ok = true;
|
|
47
52
|
}
|
|
48
53
|
else {
|
|
49
|
-
|
|
54
|
+
const result = await actionMenu(safety.reason, safety.level === 'warn', activeCmd);
|
|
55
|
+
ok = result.ok;
|
|
56
|
+
if (result.editedCmd)
|
|
57
|
+
activeCmd = result.editedCmd;
|
|
50
58
|
}
|
|
51
59
|
if (!ok) {
|
|
52
60
|
if (!config.jsonOutput)
|
|
@@ -54,39 +62,139 @@ export async function proposeAndRun(cmd, opts = {}) {
|
|
|
54
62
|
return { ran: false, exitCode: null, stdout: '', stderr: '' };
|
|
55
63
|
}
|
|
56
64
|
if (!config.autoApprove && !remembered) {
|
|
57
|
-
const remember = await
|
|
58
|
-
message: 'Remember this command for the session?',
|
|
59
|
-
|
|
65
|
+
const remember = await select({
|
|
66
|
+
message: 'Remember this command approval for the session?',
|
|
67
|
+
choices: [
|
|
68
|
+
{ name: 'No', value: false },
|
|
69
|
+
{ name: 'Yes — skip confirmation next time', value: true },
|
|
70
|
+
],
|
|
60
71
|
}).catch(() => false);
|
|
61
72
|
if (remember)
|
|
62
|
-
toolMemory.rememberShell(
|
|
73
|
+
toolMemory.rememberShell(activeCmd);
|
|
63
74
|
}
|
|
64
|
-
return runCaptured(
|
|
75
|
+
return runCaptured(activeCmd, cwd);
|
|
65
76
|
}
|
|
66
|
-
|
|
77
|
+
/** Interactive 3-choice action menu replacing plain Y/n. */
|
|
78
|
+
async function actionMenu(reason, warned, cmd) {
|
|
67
79
|
if (warned && !config.quiet && !config.jsonOutput) {
|
|
68
|
-
process.stdout.write(theme.warn(` Warning: ${reason}\n`));
|
|
80
|
+
process.stdout.write(theme.warn(` ⚠ Warning: ${reason}\n\n`));
|
|
69
81
|
}
|
|
70
|
-
|
|
71
|
-
message: '
|
|
72
|
-
|
|
82
|
+
const action = await select({
|
|
83
|
+
message: 'What would you like to do?',
|
|
84
|
+
choices: [
|
|
85
|
+
{ name: ' Run this command', value: 'run' },
|
|
86
|
+
{ name: ' Edit command before running', value: 'edit' },
|
|
87
|
+
{ name: ' Cancel and return to REPL', value: 'cancel' },
|
|
88
|
+
],
|
|
89
|
+
}).catch(() => 'cancel');
|
|
90
|
+
if (action === 'run')
|
|
91
|
+
return { ok: true };
|
|
92
|
+
if (action === 'cancel')
|
|
93
|
+
return { ok: false };
|
|
94
|
+
// Edit flow
|
|
95
|
+
const edited = await input({
|
|
96
|
+
message: 'Edit command:',
|
|
97
|
+
default: cmd,
|
|
98
|
+
}).catch(() => cmd);
|
|
99
|
+
if (!edited.trim())
|
|
100
|
+
return { ok: false };
|
|
101
|
+
process.stdout.write('\n');
|
|
102
|
+
process.stdout.write(theme.dim(' ┌─────────────────────────────────────────\n'));
|
|
103
|
+
process.stdout.write(theme.dim(' │ ') + theme.hl('$ ') + syntaxHighlightShell(edited) + '\n');
|
|
104
|
+
process.stdout.write(theme.dim(' └─────────────────────────────────────────\n\n'));
|
|
105
|
+
const confirm = await select({
|
|
106
|
+
message: 'Run the edited command?',
|
|
107
|
+
choices: [
|
|
108
|
+
{ name: ' Run', value: true },
|
|
109
|
+
{ name: ' Cancel', value: false },
|
|
110
|
+
],
|
|
73
111
|
}).catch(() => false);
|
|
112
|
+
return { ok: Boolean(confirm), editedCmd: edited };
|
|
74
113
|
}
|
|
75
|
-
async function
|
|
114
|
+
async function approveCritical(reason) {
|
|
76
115
|
if (config.autoApprove) {
|
|
77
|
-
|
|
78
|
-
process.stdout.write(theme.err(` blocked critical command: ${reason}\n`));
|
|
79
|
-
}
|
|
116
|
+
process.stdout.write(theme.err(` blocked critical command: ${reason}\n`));
|
|
80
117
|
return false;
|
|
81
118
|
}
|
|
82
|
-
process.stdout.write(theme.err(' !!! CRITICAL COMMAND WARNING !!!\n'));
|
|
83
|
-
process.stdout.write(theme.err(` Reason: ${reason}\n`));
|
|
119
|
+
process.stdout.write(theme.err('\n !!! CRITICAL COMMAND WARNING !!!\n'));
|
|
120
|
+
process.stdout.write(theme.err(` Reason: ${reason}\n\n`));
|
|
84
121
|
const answer = await input({
|
|
85
|
-
message: 'Type "yes" to
|
|
122
|
+
message: 'Type "yes" to proceed with this critical command:',
|
|
86
123
|
default: '',
|
|
87
124
|
}).catch(() => '');
|
|
88
125
|
return answer.trim() === 'yes';
|
|
89
126
|
}
|
|
127
|
+
// ─── Shell syntax highlighter ─────────────────────────────────────────────
|
|
128
|
+
// bright-green command, yellow flags, white strings, purple vars, green paths.
|
|
129
|
+
export function syntaxHighlightShell(line) {
|
|
130
|
+
// When colors are off, return raw
|
|
131
|
+
if (process.env.NO_COLOR || config.theme === 'none')
|
|
132
|
+
return line;
|
|
133
|
+
const parts = [];
|
|
134
|
+
let rest = line;
|
|
135
|
+
let isCmd = true;
|
|
136
|
+
while (rest.length > 0) {
|
|
137
|
+
// Whitespace
|
|
138
|
+
const ws = rest.match(/^(\s+)/);
|
|
139
|
+
if (ws) {
|
|
140
|
+
parts.push(ws[1]);
|
|
141
|
+
rest = rest.slice(ws[1].length);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// Pipeline / logical operators — next word is a command
|
|
145
|
+
const pipe = rest.match(/^(\|{1,2}|&&|\|\||;;|;)/);
|
|
146
|
+
if (pipe) {
|
|
147
|
+
parts.push('\x1b[90m' + pipe[1] + '\x1b[0m');
|
|
148
|
+
rest = rest.slice(pipe[1].length);
|
|
149
|
+
isCmd = true;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
// Quoted strings (single or double)
|
|
153
|
+
const str = rest.match(/^(["'])((?:\\.|(?!\1).)*)(\1)/s);
|
|
154
|
+
if (str) {
|
|
155
|
+
parts.push('\x1b[97m' + str[0] + '\x1b[0m'); // bright white
|
|
156
|
+
rest = rest.slice(str[0].length);
|
|
157
|
+
isCmd = false;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
// Variables $VAR or ${VAR}
|
|
161
|
+
const varT = rest.match(/^(\$\{[^}]+\}|\$\w+)/);
|
|
162
|
+
if (varT) {
|
|
163
|
+
parts.push('\x1b[35m' + varT[1] + '\x1b[0m'); // magenta
|
|
164
|
+
rest = rest.slice(varT[1].length);
|
|
165
|
+
isCmd = false;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// Flags --flag / -f
|
|
169
|
+
const flag = rest.match(/^(--?[\w][\w-]*)/);
|
|
170
|
+
if (flag) {
|
|
171
|
+
parts.push('\x1b[33m' + flag[1] + '\x1b[0m'); // yellow
|
|
172
|
+
rest = rest.slice(flag[1].length);
|
|
173
|
+
isCmd = false;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
// Word token (command, path, or plain arg)
|
|
177
|
+
const word = rest.match(/^([^\s|;&'"$\\]+)/);
|
|
178
|
+
if (word) {
|
|
179
|
+
const w = word[1];
|
|
180
|
+
if (isCmd) {
|
|
181
|
+
parts.push('\x1b[92m\x1b[1m' + w + '\x1b[0m'); // bright green bold
|
|
182
|
+
isCmd = false;
|
|
183
|
+
}
|
|
184
|
+
else if (/^[./~]/.test(w) || /\//.test(w)) {
|
|
185
|
+
parts.push('\x1b[32m' + w + '\x1b[0m'); // green (path)
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
parts.push(w);
|
|
189
|
+
}
|
|
190
|
+
rest = rest.slice(w.length);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
parts.push(rest[0]);
|
|
194
|
+
rest = rest.slice(1);
|
|
195
|
+
}
|
|
196
|
+
return parts.join('');
|
|
197
|
+
}
|
|
90
198
|
function runCaptured(cmd, cwd) {
|
|
91
199
|
return new Promise((resolve) => {
|
|
92
200
|
const isWin = process.platform === 'win32';
|
package/dist/ui/box.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { theme } from './theme.js';
|
|
2
|
+
import { size } from './screen.js';
|
|
3
|
+
/**
|
|
4
|
+
* Render a GitHub Copilot CLI-style bordered panel.
|
|
5
|
+
*
|
|
6
|
+
* ╭─ Copilot ────────────────╮
|
|
7
|
+
* │ content line │
|
|
8
|
+
* ╰──────────────────────────╯
|
|
9
|
+
*/
|
|
10
|
+
export function box(content, opts = {}) {
|
|
11
|
+
const cols = opts.width ?? Math.min(size().cols, 100);
|
|
12
|
+
const pad = opts.padding ?? 1;
|
|
13
|
+
const innerWidth = cols - 2; // subtract left/right border chars
|
|
14
|
+
const title = opts.title ?? '';
|
|
15
|
+
const topTitle = title ? ` ${title} ` : '';
|
|
16
|
+
const topFill = innerWidth - topTitle.length;
|
|
17
|
+
const topLeft = topFill < 0 ? 0 : Math.floor(topFill / 2);
|
|
18
|
+
const topRight = topFill < 0 ? 0 : topFill - topLeft - (title ? 0 : 0);
|
|
19
|
+
const top = `╭${'─'.repeat(topLeft)}${topTitle}${'─'.repeat(Math.max(0, topRight))}╮`;
|
|
20
|
+
const bottom = `╰${'─'.repeat(innerWidth)}╯`;
|
|
21
|
+
const lines = content.split('\n');
|
|
22
|
+
const paddingStr = ' '.repeat(pad);
|
|
23
|
+
const contentWidth = innerWidth - pad * 2;
|
|
24
|
+
const bodyLines = [];
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
const stripped = stripAnsi(line);
|
|
27
|
+
if (stripped.length <= contentWidth) {
|
|
28
|
+
const fill = ' '.repeat(Math.max(0, contentWidth - stripped.length));
|
|
29
|
+
const colored = line; // keep original ANSI
|
|
30
|
+
bodyLines.push(`│${paddingStr}${colored}${fill}${paddingStr}│`);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
// wrap long lines
|
|
34
|
+
let remaining = line;
|
|
35
|
+
let remainingStripped = stripped;
|
|
36
|
+
while (remainingStripped.length > contentWidth) {
|
|
37
|
+
const chunk = remaining.slice(0, contentWidth);
|
|
38
|
+
const fill = ' '.repeat(Math.max(0, contentWidth - contentWidth));
|
|
39
|
+
bodyLines.push(`│${paddingStr}${chunk}${fill}${paddingStr}│`);
|
|
40
|
+
remaining = remaining.slice(contentWidth);
|
|
41
|
+
remainingStripped = remainingStripped.slice(contentWidth);
|
|
42
|
+
}
|
|
43
|
+
if (remaining.length > 0) {
|
|
44
|
+
const fill = ' '.repeat(Math.max(0, contentWidth - remainingStripped.length));
|
|
45
|
+
bodyLines.push(`│${paddingStr}${remaining}${fill}${paddingStr}│`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const styleTop = opts.style === 'command'
|
|
50
|
+
? theme.hl(top)
|
|
51
|
+
: opts.style === 'response'
|
|
52
|
+
? theme.brand(top)
|
|
53
|
+
: theme.dim(top);
|
|
54
|
+
const styleBottom = opts.style === 'command'
|
|
55
|
+
? theme.hl(bottom)
|
|
56
|
+
: opts.style === 'response'
|
|
57
|
+
? theme.brand(bottom)
|
|
58
|
+
: theme.dim(bottom);
|
|
59
|
+
const styleSide = (s) => opts.style === 'command'
|
|
60
|
+
? theme.hl(s)
|
|
61
|
+
: opts.style === 'response'
|
|
62
|
+
? theme.brand(s)
|
|
63
|
+
: theme.dim(s);
|
|
64
|
+
const styledBody = bodyLines.map((l) => {
|
|
65
|
+
const left = l.slice(0, 1);
|
|
66
|
+
const right = l.slice(-1);
|
|
67
|
+
const mid = l.slice(1, -1);
|
|
68
|
+
return `${styleSide(left)}${mid}${styleSide(right)}`;
|
|
69
|
+
});
|
|
70
|
+
return [styleTop, ...styledBody, styleBottom].join('\n') + '\n';
|
|
71
|
+
}
|
|
72
|
+
/** Single-line command display (compact version for inline command rendering). */
|
|
73
|
+
export function commandChip(cmd) {
|
|
74
|
+
return `${theme.hl('❯')} ${theme.hl(cmd)}`;
|
|
75
|
+
}
|
|
76
|
+
function stripAnsi(text) {
|
|
77
|
+
// eslint-disable-next-line no-control-regex
|
|
78
|
+
return text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '');
|
|
79
|
+
}
|