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.
@@ -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
- export async function suggestCommand(query, session, signal) {
10
- const trimmedQuery = query.trim();
11
- if (!trimmedQuery)
12
- return theme.warn('usage: /suggest <request>\n');
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: `Current working directory: ${session.state.cwd}\nRequest: ${trimmedQuery}`,
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
- const command = sanitizeSuggestion(result.content || suggestion);
31
- return `${theme.brand('Suggested command')}\n ${theme.hl(command)}\n`;
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('2.0.0')
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
- process.stdout.write(banner(VERSION, session.state.model));
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
- writeRaw('─'.repeat(cols));
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 prompt = `${busy ? '' : ''} ${rl.line || ''}`;
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
- writeRaw(pad(busy ? 'Working…' : 'Enter to send • /help for commands', cols));
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 text = ` iCopilot v${VERSION} • model: ${session.state.model} mode: ${mode} Ctrl+C to exit`;
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) {
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import path from 'node:path';
3
- import { confirm, input } from '@inquirer/prompts';
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, require confirmation, then run it.
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' + theme.badge('SHELL') + '\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(theme.hl(' $ ') + cmd + '\n');
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 approveCriticalCommand(safety.reason).catch(() => false);
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
- ok = await approveCommand(safety.reason, safety.level === 'warn');
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 confirm({
58
- message: 'Remember this command for the session?',
59
- default: false,
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(cmd);
73
+ toolMemory.rememberShell(activeCmd);
63
74
  }
64
- return runCaptured(cmd, cwd);
75
+ return runCaptured(activeCmd, cwd);
65
76
  }
66
- async function approveCommand(reason, warned) {
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
- return confirm({
71
- message: 'Run this command?',
72
- default: false,
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 approveCriticalCommand(reason) {
114
+ async function approveCritical(reason) {
76
115
  if (config.autoApprove) {
77
- if (!config.quiet && !config.jsonOutput) {
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 run this critical command:',
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
+ }