ispbills-icli 3.0.0 → 4.0.0

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/bin/icli.js CHANGED
@@ -4,16 +4,23 @@ import { stdin as input, stdout as output, argv, exit } from 'process';
4
4
  import chalk from 'chalk';
5
5
  import { loadConfig, configPath } from '../src/config.js';
6
6
  import { loginFlow } from '../src/auth.js';
7
+ import { cols, inner, onResize, boxTop, boxBot, boxMid, boxRow, boxTopOpen, boxBotOpen } from '../src/layout.js';
7
8
  import {
8
- streamChat, printBanner, printSlashMenu,
9
- SLASH_COMMANDS, slashCompleter, cols,
9
+ streamChat, printBanner, printSlashMenu, printUserBox,
10
+ SLASH_COMMANDS, slashCompleter,
10
11
  } from '../src/chat.js';
11
12
 
12
- // ── Copilot CLI colour tokens ──────────────────────────────────────────────────
13
- const dim = chalk.hex('#9198A1');
14
- const tertiary = chalk.hex('#6e7781');
15
- const accent = chalk.hex('#4493F8').bold;
16
- const rule = () => chalk.hex('#3D444D')(' ' + '─'.repeat(Math.max(0, cols() - 4)));
13
+ const P = {
14
+ border: chalk.hex('#3D444D'),
15
+ dim: chalk.hex('#9198A1'),
16
+ muted: chalk.hex('#6e7781'),
17
+ accent: chalk.hex('#4493F8').bold,
18
+ accentD: chalk.hex('#4493F8'),
19
+ bold: chalk.bold.white,
20
+ ok: chalk.green,
21
+ warn: chalk.yellow,
22
+ err: chalk.red,
23
+ };
17
24
 
18
25
  const args = argv.slice(2);
19
26
  const cmd = args[0] ?? '';
@@ -21,51 +28,54 @@ const cmd = args[0] ?? '';
21
28
  // ── Help ───────────────────────────────────────────────────────────────────────
22
29
  function printHelp() {
23
30
  process.stdout.write('\n');
24
- process.stdout.write(' ' + chalk.bold.white('iCli') + dim(' · IspBills AI Terminal\n'));
25
- process.stdout.write(rule() + '\n\n');
31
+ process.stdout.write(boxTop('iCli · Help', P.border) + '\n');
26
32
 
27
- process.stdout.write(chalk.bold.white(' Usage\n\n'));
28
- const cmds = [
33
+ process.stdout.write(boxRow('', P.border) + '\n');
34
+ process.stdout.write(boxRow(' ' + P.bold('Usage'), P.border) + '\n');
35
+ process.stdout.write(boxRow('', P.border) + '\n');
36
+ [
29
37
  ['icli', 'Interactive REPL with slash commands'],
30
38
  ['icli "question"', 'Single-shot query then exit'],
31
39
  ['icli login', 'Authenticate to your IspBills instance'],
32
40
  ['icli whoami', 'Show current session info'],
33
41
  ['icli --help', 'Show this help'],
34
- ];
35
- for (const [c, d] of cmds) {
36
- process.stdout.write(' ' + accent(c.padEnd(20)) + dim(d) + '\n');
37
- }
42
+ ].forEach(([c, d]) =>
43
+ process.stdout.write(boxRow(' ' + P.accent(c.padEnd(20)) + P.dim(d), P.border) + '\n')
44
+ );
38
45
 
39
- process.stdout.write('\n' + chalk.bold.white(' Slash commands\n\n'));
46
+ process.stdout.write(boxMid(P.border) + '\n');
47
+ process.stdout.write(boxRow(' ' + P.bold('Slash commands'), P.border) + '\n');
48
+ process.stdout.write(boxRow('', P.border) + '\n');
40
49
  for (const { cmd: c, hint, desc } of SLASH_COMMANDS) {
41
- const key = (c + (hint ? ' ' + hint : '')).padEnd(22);
42
- process.stdout.write(' ' + accent(key) + dim(desc) + '\n');
50
+ const key = (c + (hint ? ' '+hint : '')).padEnd(22);
51
+ process.stdout.write(boxRow(' ' + P.accent(key) + P.dim(desc), P.border) + '\n');
43
52
  }
44
53
 
45
- process.stdout.write('\n' + chalk.bold.white(' Keyboard shortcuts\n\n'));
54
+ process.stdout.write(boxMid(P.border) + '\n');
55
+ process.stdout.write(boxRow(' ' + P.bold('Keyboard shortcuts'), P.border) + '\n');
56
+ process.stdout.write(boxRow('', P.border) + '\n');
46
57
  [
47
- ['Tab', 'Autocomplete slash commands'],
48
- ['↑ / ↓', 'Navigate input history'],
49
- ['Ctrl+T', 'Toggle reasoning display'],
50
- ['Ctrl+L', 'Clear screen'],
51
- ['Ctrl+C', 'Cancel / exit'],
52
- ].forEach(([k, d]) => {
53
- process.stdout.write(' ' + chalk.yellow(k.padEnd(14)) + dim(d) + '\n');
54
- });
55
- process.stdout.write('\n');
58
+ ['Tab', 'Autocomplete slash commands'],
59
+ ['↑ / ↓', 'Navigate input history'],
60
+ ['Ctrl+T', 'Toggle reasoning display'],
61
+ ['Ctrl+L', 'Clear screen'],
62
+ ['Ctrl+C', 'Cancel / exit'],
63
+ ].forEach(([k, d]) =>
64
+ process.stdout.write(boxRow(' ' + P.warn(k.padEnd(14)) + P.dim(d), P.border) + '\n')
65
+ );
66
+ process.stdout.write(boxRow('', P.border) + '\n');
67
+ process.stdout.write(boxBot(P.border) + '\n\n');
56
68
  }
57
69
 
58
70
  // ── Auth ───────────────────────────────────────────────────────────────────────
59
71
  async function ensureAuth() {
60
72
  let cfg = loadConfig();
61
73
  if (!cfg) {
62
- process.stdout.write(chalk.yellow('\n No saved credentials. Setting up iCli.\n\n'));
74
+ process.stdout.write(P.warn('\n No saved credentials. Setting up iCli.\n\n'));
63
75
  cfg = await loginFlow();
64
- process.stdout.write(
65
- '\n ' + chalk.green('✓') + ' Logged in as ' +
76
+ process.stdout.write('\n ' + P.ok('✓') + ' Logged in as ' +
66
77
  chalk.white(cfg.user?.name ?? cfg.user?.email) +
67
- dim(' · ' + (cfg.user?.role ?? 'operator')) + '\n\n'
68
- );
78
+ P.dim(' · ' + (cfg.user?.role ?? 'operator')) + '\n\n');
69
79
  }
70
80
  return cfg;
71
81
  }
@@ -87,16 +97,11 @@ function slashToQuestion(line) {
87
97
 
88
98
  // ── Single-shot ────────────────────────────────────────────────────────────────
89
99
  async function singleShot(cfg, question) {
90
- // Copilot CLI style: role label then content, separated by blank lines
91
- process.stdout.write('\n ' + chalk.hex('#4493F8').bold('You') + '\n');
92
- process.stdout.write(' ' + chalk.white(question) + '\n\n');
93
- process.stdout.write(rule() + '\n\n');
94
- process.stdout.write(' ' + chalk.bold.white('iCopilot') + '\n');
95
-
100
+ printUserBox(question);
96
101
  try {
97
102
  await streamChat(cfg, [{ role: 'user', content: question }]);
98
103
  } catch (e) {
99
- process.stdout.write('\n ' + chalk.red('✖ ') + chalk.white(e.message) + '\n\n');
104
+ process.stdout.write('\n ' + P.err('✖ ') + chalk.white(e.message) + '\n\n');
100
105
  exit(1);
101
106
  }
102
107
  }
@@ -105,30 +110,26 @@ async function singleShot(cfg, question) {
105
110
  async function interactiveRepl(cfg) {
106
111
  const history = [];
107
112
  let showReasoning = true;
113
+ const PROMPT = () => ' ' + P.accentD('❯') + ' ';
108
114
 
109
115
  printBanner(cfg);
110
116
 
111
- // Copilot CLI uses `> ` style prompt
112
- const PROMPT = ' ' + chalk.hex('#4493F8').bold('>') + ' ';
113
-
114
117
  const rl = createInterface({
115
118
  input, output,
116
- prompt: PROMPT,
119
+ prompt: PROMPT(),
117
120
  completer: slashCompleter,
118
121
  terminal: true,
119
122
  });
120
123
 
121
- process.stdout.on('resize', () => rl.setPrompt(PROMPT));
122
-
124
+ onResize(() => rl.setPrompt(PROMPT()));
123
125
  rl.prompt();
124
126
 
125
127
  rl.on('line', async (line) => {
126
128
  const q = line.trim();
127
129
  if (!q) { rl.prompt(); return; }
128
130
 
129
- // ── Built-in commands ──────────────────────────────────────────────────
130
131
  if (q === '/exit' || q === 'exit' || q === 'quit') {
131
- process.stdout.write(tertiary('\n Goodbye.\n\n')); rl.close(); exit(0);
132
+ process.stdout.write(P.muted('\n Goodbye.\n\n')); rl.close(); exit(0);
132
133
  }
133
134
  if (q === '/clear' || q === 'clear') {
134
135
  process.stdout.write('\x1Bc'); printBanner(cfg); rl.prompt(); return;
@@ -137,59 +138,46 @@ async function interactiveRepl(cfg) {
137
138
  if (q === '/history' || q === 'history') {
138
139
  const turns = history.filter(m => m.role === 'user');
139
140
  if (!turns.length) {
140
- process.stdout.write(tertiary('\n No history yet.\n\n'));
141
+ process.stdout.write(P.muted('\n No history yet.\n\n'));
141
142
  } else {
142
- process.stdout.write('\n');
143
+ process.stdout.write('\n' + boxTop('History', P.border) + '\n');
143
144
  turns.forEach((m, i) =>
144
- process.stdout.write(' ' + dim(String(i+1).padStart(2)+'.') + ' ' + chalk.white(m.content.slice(0, cols()-8)) + '\n')
145
+ process.stdout.write(boxRow(' ' + P.dim(String(i+1).padStart(2)+'.') + ' ' +
146
+ chalk.white(m.content.slice(0, inner() - 8)), P.border) + '\n')
145
147
  );
146
- process.stdout.write('\n');
148
+ process.stdout.write(boxBot(P.border) + '\n\n');
147
149
  }
148
150
  rl.prompt(); return;
149
151
  }
150
152
  if (q === '/') { printSlashMenu(); rl.prompt(); return; }
151
153
 
152
- // ── Resolve slash command ──────────────────────────────────────────────
153
154
  let question = q;
154
155
  if (q.startsWith('/')) {
155
156
  const resolved = slashToQuestion(q);
156
157
  if (resolved) {
157
158
  question = resolved;
158
- process.stdout.write(' ' + tertiary('→ ') + dim(question) + '\n');
159
+ process.stdout.write(' ' + P.muted('→ ') + P.dim(question) + '\n');
159
160
  }
160
161
  }
161
162
 
162
163
  rl.pause();
163
164
  history.push({ role: 'user', content: question });
164
165
 
165
- // Role separator — Copilot CLI style blank-line + label pattern
166
- process.stdout.write('\n' + rule() + '\n\n');
167
- process.stdout.write(' ' + chalk.bold.white('iCopilot') + '\n');
166
+ // User message box
167
+ printUserBox(question);
168
168
 
169
169
  try {
170
170
  const answer = await streamChat(cfg, [...history], {}, { thinking: showReasoning });
171
171
  if (answer) history.push({ role: 'assistant', content: answer });
172
172
  } catch (e) {
173
- process.stdout.write('\n ' + chalk.red('✖ ') + chalk.white(e.message) + '\n\n');
173
+ process.stdout.write('\n ' + P.err('✖ ') + chalk.white(e.message) + '\n\n');
174
174
  }
175
175
 
176
- process.stdout.write(rule() + '\n\n');
177
176
  rl.resume();
178
177
  rl.prompt();
179
178
  });
180
179
 
181
- // Ctrl+T toggles reasoning (matches Copilot CLI Ctrl+T)
182
- if (input.setRawMode) {
183
- process.stdin.on('keypress', (str, key) => {
184
- if (key?.ctrl && key?.name === 't') {
185
- showReasoning = !showReasoning;
186
- process.stdout.write(tertiary(`\n Reasoning ${showReasoning ? 'on' : 'off'}\n\n`));
187
- rl.prompt();
188
- }
189
- });
190
- }
191
-
192
- rl.on('close', () => { process.stdout.write(tertiary('\n Goodbye.\n\n')); exit(0); });
180
+ rl.on('close', () => { process.stdout.write(P.muted('\n Goodbye.\n\n')); exit(0); });
193
181
  }
194
182
 
195
183
  // ── Main ───────────────────────────────────────────────────────────────────────
@@ -198,31 +186,26 @@ async function main() {
198
186
 
199
187
  if (cmd === 'login') {
200
188
  const cfg = await loginFlow();
201
- process.stdout.write(
202
- '\n ' + chalk.green('✓') + ' Logged in as ' +
189
+ process.stdout.write('\n ' + P.ok('✓') + ' Logged in as ' +
203
190
  chalk.white(cfg.user?.name ?? cfg.user?.email) +
204
- dim(' · ' + (cfg.user?.role ?? 'operator')) + '\n'
205
- );
206
- process.stdout.write(' ' + dim('Config: ' + configPath()) + '\n\n');
191
+ P.dim(' · ' + (cfg.user?.role ?? 'operator')) + '\n');
192
+ process.stdout.write(' ' + P.dim('Config: ' + configPath()) + '\n\n');
207
193
  return;
208
194
  }
209
195
 
210
196
  if (cmd === 'whoami') {
211
197
  const cfg = loadConfig();
212
198
  if (!cfg) {
213
- process.stdout.write('\n ' + chalk.yellow('Not logged in.') + ' Run ' + accent('icli login') + '\n\n');
199
+ process.stdout.write('\n ' + P.warn('Not logged in.') + ' Run ' + P.accent('icli login') + '\n\n');
214
200
  return;
215
201
  }
216
- process.stdout.write('\n' + rule() + '\n');
217
- [
218
- ['url', cfg.url],
219
- ['name', cfg.user?.name],
220
- ['email', cfg.user?.email],
221
- ['role', cfg.user?.role],
222
- ].forEach(([k, v]) => {
223
- if (v) process.stdout.write(' ' + dim(k.padEnd(7)) + chalk.white(v) + '\n');
224
- });
225
- process.stdout.write(rule() + '\n\n');
202
+ process.stdout.write('\n' + boxTop('Session', P.border) + '\n');
203
+ [['url', cfg.url], ['name', cfg.user?.name], ['email', cfg.user?.email], ['role', cfg.user?.role]]
204
+ .filter(([, v]) => v)
205
+ .forEach(([k, v]) =>
206
+ process.stdout.write(boxRow(' ' + P.dim(k.padEnd(7)) + chalk.white(v), P.border) + '\n')
207
+ );
208
+ process.stdout.write(boxBot(P.border) + '\n\n');
226
209
  return;
227
210
  }
228
211
 
@@ -237,6 +220,6 @@ async function main() {
237
220
  }
238
221
 
239
222
  main().catch(e => {
240
- process.stdout.write('\n ' + chalk.red('✖ ') + chalk.white(e.message) + '\n\n');
223
+ process.stdout.write('\n ' + P.err('✖ ') + chalk.white(e.message) + '\n\n');
241
224
  exit(1);
242
225
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
package/src/chat.js CHANGED
@@ -3,38 +3,37 @@ import ora from 'ora';
3
3
  import { marked } from 'marked';
4
4
  import TerminalRenderer from 'marked-terminal';
5
5
  import { refreshToken } from './auth.js';
6
-
7
- // ── Copilot CLI colour palette (matched from app.js theme tokens) ─────────────
8
- const C = {
9
- textPrimary: chalk.white,
10
- textSecondary: chalk.hex('#9198A1'),
11
- textTertiary: chalk.hex('#6e7781'),
12
- accent: chalk.hex('#4493F8'),
13
- border: chalk.hex('#3D444D'),
14
- userLabel: chalk.hex('#4493F8').bold, // same as selected/accent
15
- assistantLabel: chalk.white.bold,
16
- statusOk: chalk.green,
17
- statusErr: chalk.red,
18
- statusWarn: chalk.yellow,
19
- code: chalk.hex('#79c0ff'), // syntaxString dark
20
- codeBg: chalk.bgHex('#161b22').hex('#c9d1d9'),
6
+ import { cols, inner, visLen, padRight, stripAnsi,
7
+ boxTop, boxMid, boxBot, boxTopOpen, boxBotOpen,
8
+ boxRow, boxLine } from './layout.js';
9
+
10
+ // ── Copilot CLI palette ────────────────────────────────────────────────────────
11
+ const P = {
12
+ border: chalk.hex('#3D444D'),
13
+ dim: chalk.hex('#9198A1'),
14
+ muted: chalk.hex('#6e7781'),
15
+ accent: chalk.hex('#4493F8'),
16
+ accentB: chalk.hex('#4493F8').bold,
17
+ white: chalk.white,
18
+ bold: chalk.bold.white,
19
+ ok: chalk.green,
20
+ err: chalk.red,
21
+ warn: chalk.yellow,
22
+ cyan: chalk.cyan,
23
+ reasoning: chalk.hex('#9198A1').italic,
21
24
  };
22
25
 
23
- // ── Layout ────────────────────────────────────────────────────────────────────
24
- export const cols = () => Math.min((process.stdout.columns || 80), 88);
25
- const rule = (ch = '─') => C.border(' ' + ch.repeat(Math.max(0, cols() - 4)));
26
-
27
- // ── Markdown (matched to Copilot CLI syntax colours) ──────────────────────────
26
+ // ── Markdown ───────────────────────────────────────────────────────────────────
28
27
  marked.setOptions({ renderer: new TerminalRenderer({
29
28
  firstHeading: chalk.bold.white,
30
29
  heading: chalk.bold.white,
31
30
  strong: chalk.bold.white,
32
31
  em: chalk.italic.hex('#9198A1'),
33
32
  codespan: (t) => chalk.bgHex('#161b22').hex('#c9d1d9')(` ${t} `),
34
- code: (t) => '\n' + t.split('\n').map(l => ' ' + chalk.hex('#c9d1d9')(l)).join('\n') + '\n',
35
- blockquote: (t) => C.textSecondary(' ▌ ' + t.trim()),
36
- listitem: (t) => ` ${C.accent('·')} ${t.trimEnd()}`,
37
- hr: () => rule() + '\n',
33
+ code: (t) => '\n' + t.split('\n').map(l => ' ' + chalk.hex('#a5d6ff')(l)).join('\n') + '\n',
34
+ blockquote: (t) => P.dim(' ▌ ') + P.dim(t.trim()),
35
+ listitem: (t) => ` ${P.accent('·')} ${t.trimEnd()}`,
36
+ hr: () => P.border('─'.repeat(inner())) + '\n',
38
37
  link: (_h, _t, t) => chalk.hex('#58a6ff').underline(t || _h),
39
38
  }) });
40
39
 
@@ -42,50 +41,68 @@ function renderMarkdown(text) {
42
41
  try { return marked(text).trimEnd(); } catch { return text; }
43
42
  }
44
43
 
45
- // ── Status icon (same pattern, cleaner) ───────────────────────────────────────
44
+ // ── Status icon ────────────────────────────────────────────────────────────────
46
45
  function statusIcon(line) {
47
46
  const l = line.toLowerCase();
48
- if (/unreachable|skip|fail|error/.test(l)) return C.statusErr('✗');
49
- if (/done|success|found|complet|online/.test(l)) return C.statusOk('✓');
50
- if (/batch|sub.?agent|fleet|parallel/.test(l)) return C.accent('◈');
51
- if (/\[\d+\/\d+\]/.test(l)) return C.accent('◉');
52
- if (/probe|reachab|ping/.test(l)) return C.statusWarn('◎');
53
- if (/read|fetch|get|query/.test(l)) return C.textSecondary('');
54
- if (/connect|ssh|telnet|api/.test(l)) return C.accent('');
55
- if (/⌁/.test(line)) return C.border('⌁');
56
- return C.border('›');
47
+ if (/unreachable|skip|fail|error/.test(l)) return P.err('✗');
48
+ if (/done|success|found|complet|online/.test(l)) return P.ok('✓');
49
+ if (/batch|sub.?agent|fleet|parallel/.test(l)) return P.accentB('◈');
50
+ if (/\[\d+\/\d+\]/.test(l)) return P.accent('◉');
51
+ if (/probe|reachab|ping/.test(l)) return P.warn('◎');
52
+ if (/connect|ssh|telnet|api/.test(l)) return P.accent('');
53
+ if (/read|fetch|get|query/.test(l)) return P.dim('');
54
+ if (/⌁/.test(line)) return P.border('⌁');
55
+ return P.border('›');
57
56
  }
58
57
 
59
- // ── Banner (Copilot CLI style: label · info · shortcuts) ─────────────────────
58
+ const shortModel = (m) =>
59
+ m.replace('openai/', '').replace(/^claude-/, '').replace(/^gpt-/, 'gpt-');
60
+
61
+ // ── Banner ────────────────────────────────────────────────────────────────────
60
62
  export function printBanner(cfg, meta = {}) {
61
63
  const model = meta.model ? shortModel(meta.model) : (cfg?._model ?? null);
64
+ const w = cols();
65
+
62
66
  process.stdout.write('\n');
63
67
 
64
- // Title row — bold label, dim subtitle
65
- process.stdout.write(
66
- ' ' + chalk.bold.white('iCopilot') +
67
- C.textSecondary(' · IspBills AI Network Engineer') + '\n'
68
- );
69
-
70
- // Session info row (model · user · role) — matches Copilot CLI footer format
71
- const parts = [];
72
- if (model) parts.push(C.textSecondary('model ') + chalk.white(model));
73
- if (cfg?.user?.name) parts.push(C.textSecondary('user ') + chalk.white(cfg.user.name));
74
- if (cfg?.user?.role) parts.push(C.textSecondary('role ') + chalk.white(cfg.user.role));
75
- if (cfg?.url) parts.push(C.textSecondary('url ') + C.accent(cfg.url));
76
- if (parts.length) {
77
- process.stdout.write(' ' + parts.join(C.textSecondary(' · ')) + '\n');
68
+ // Top border
69
+ process.stdout.write(boxTop('', P.border) + '\n');
70
+
71
+ // Title row
72
+ const title = P.bold(' iCopilot') + P.dim(' · IspBills AI Network Engineer');
73
+ process.stdout.write(boxRow(title, P.border) + '\n');
74
+
75
+ // Divider
76
+ process.stdout.write(boxMid(P.border) + '\n');
77
+
78
+ // Session info rows
79
+ const row1parts = [];
80
+ if (model) row1parts.push(P.dim('model ') + P.white(model));
81
+ if (cfg?.user?.name) row1parts.push(P.dim('user ') + P.white(cfg.user.name));
82
+ if (cfg?.user?.role) row1parts.push(P.dim('role ') + P.white(cfg.user.role));
83
+ if (row1parts.length) {
84
+ process.stdout.write(boxRow(' ' + row1parts.join(P.dim(' · ')), P.border) + '\n');
85
+ }
86
+ if (cfg?.url) {
87
+ const maxUrl = inner() - 8;
88
+ const url = cfg.url.length > maxUrl ? cfg.url.slice(0, maxUrl - 1) + '…' : cfg.url;
89
+ process.stdout.write(boxRow(' ' + P.dim('url ') + P.accent(url), P.border) + '\n');
78
90
  }
79
91
 
80
- // Hint row — matches Copilot CLI shortcut hints
81
- process.stdout.write(
82
- C.textTertiary(' / commands Tab autocomplete ↑↓ history Ctrl+C exit') + '\n'
83
- );
84
- process.stdout.write(rule() + '\n\n');
85
- }
92
+ // Divider
93
+ process.stdout.write(boxMid(P.border) + '\n');
86
94
 
87
- function shortModel(m) {
88
- return m.replace('openai/', '').replace(/^claude-/, '').replace(/^gpt-/, 'gpt-');
95
+ // Hints row
96
+ const hints = ' ' + [
97
+ P.accentB('/') + P.muted(' commands'),
98
+ P.accentB('Tab') + P.muted(' autocomplete'),
99
+ P.accentB('↑↓') + P.muted(' history'),
100
+ P.accentB('Ctrl+C') + P.muted(' exit'),
101
+ ].join(P.muted(' '));
102
+ process.stdout.write(boxRow(hints, P.border) + '\n');
103
+
104
+ // Bottom border
105
+ process.stdout.write(boxBot(P.border) + '\n\n');
89
106
  }
90
107
 
91
108
  // ── Slash commands ─────────────────────────────────────────────────────────────
@@ -104,12 +121,13 @@ export const SLASH_COMMANDS = [
104
121
 
105
122
  export function printSlashMenu() {
106
123
  process.stdout.write('\n');
107
- process.stdout.write(chalk.bold.white(' Commands') + C.textTertiary(' (Tab to autocomplete)\n\n'));
124
+ process.stdout.write(boxTop('Commands', P.border) + '\n');
108
125
  for (const { cmd: c, hint, desc } of SLASH_COMMANDS) {
109
- const key = (c + (hint ? ' ' + hint : '')).padEnd(22);
110
- process.stdout.write(' ' + C.accent(key) + C.textSecondary(desc) + '\n');
126
+ const key = P.accentB(c) + (hint ? P.dim(' ' + hint) : '');
127
+ const line = ' ' + padRight(key, 22) + ' ' + P.dim(desc);
128
+ process.stdout.write(boxRow(line, P.border) + '\n');
111
129
  }
112
- process.stdout.write('\n');
130
+ process.stdout.write(boxBot(P.border) + '\n\n');
113
131
  }
114
132
 
115
133
  export function slashCompleter(line) {
@@ -120,6 +138,26 @@ export function slashCompleter(line) {
120
138
  return [[], line];
121
139
  }
122
140
 
141
+ // ── User message box ───────────────────────────────────────────────────────────
142
+ export function printUserBox(question) {
143
+ process.stdout.write('\n');
144
+ process.stdout.write(boxTop('You', P.accentB) + '\n');
145
+ // Word-wrap the question to fit inside the box
146
+ const maxW = inner() - 2;
147
+ const words = question.split(' ');
148
+ let line = '';
149
+ for (const word of words) {
150
+ if (line.length + word.length + 1 > maxW && line) {
151
+ process.stdout.write(boxRow(' ' + P.white(line), P.accentB) + '\n');
152
+ line = word;
153
+ } else {
154
+ line = line ? line + ' ' + word : word;
155
+ }
156
+ }
157
+ if (line) process.stdout.write(boxRow(' ' + P.white(line), P.accentB) + '\n');
158
+ process.stdout.write(boxBot(P.accentB) + '\n');
159
+ }
160
+
123
161
  // ── streamChat ────────────────────────────────────────────────────────────────
124
162
  export async function streamChat(cfg, messages, context = {}, opts = {}) {
125
163
  const showThinking = opts.thinking !== false;
@@ -149,17 +187,26 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
149
187
  throw new Error(`AI request failed (HTTP ${res.status}): ${txt.slice(0, 200)}`);
150
188
  }
151
189
 
152
- // ── Render state ───────────────────────────────────────────────────────────
153
- let reasoningOpen = false; // true while inside a live reasoning block
190
+ // ── Streaming state ─────────────────────────────────────────────────────────
191
+ let boxOpen = false; // have we printed ╭─ iCopilot ?
192
+ let reasoningOpen = false;
154
193
  let answerBuf = '';
155
194
  let rawAnswer = '';
156
195
  let finalResult = null;
157
196
 
197
+ // Open the iCopilot response box
198
+ function ensureBoxOpen() {
199
+ if (boxOpen) return;
200
+ boxOpen = true;
201
+ process.stdout.write('\n' + boxTopOpen('iCopilot', P.border) + '\n');
202
+ }
203
+
204
+ // Ora spinner — sits on a │ prefixed line
158
205
  const spinner = ora({
159
- text: C.textSecondary('Thinking…'),
206
+ text: P.dim('Thinking…'),
160
207
  color: 'blue',
161
208
  spinner: 'dots',
162
- prefixText: ' ',
209
+ prefixText: P.border('│') + ' ',
163
210
  });
164
211
 
165
212
  const stopSpinner = () => { if (spinner.isSpinning) spinner.stop(); };
@@ -168,33 +215,34 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
168
215
  if (reasoningOpen) return;
169
216
  stopSpinner();
170
217
  reasoningOpen = true;
171
- // Copilot CLI style: label then indented content
172
- process.stdout.write('\n ' + C.textSecondary('Reasoning') + '\n');
173
- process.stdout.write(C.border(' │ '));
218
+ process.stdout.write(boxLine('', P.border) + '\n');
219
+ process.stdout.write(boxLine(P.dim(' ╌ Reasoning ') + P.border(''.repeat(Math.max(0, inner() - 16))), P.border) + '\n');
220
+ process.stdout.write(boxLine(P.border(' │ '), P.border));
174
221
  }
175
222
 
176
223
  function writeReasonDelta(delta) {
177
224
  if (!reasoningOpen) return;
178
225
  const parts = delta.split('\n');
179
226
  for (let i = 0; i < parts.length; i++) {
180
- if (i > 0) process.stdout.write('\n' + C.border(' │ '));
181
- if (parts[i]) process.stdout.write(C.textSecondary(parts[i]));
227
+ if (i > 0) process.stdout.write('\n' + boxLine(P.border(' │ '), P.border));
228
+ if (parts[i]) process.stdout.write(P.reasoning(parts[i]));
182
229
  }
183
230
  }
184
231
 
185
232
  function closeReasoning() {
186
233
  if (!reasoningOpen) return;
187
- process.stdout.write('\n\n');
234
+ process.stdout.write('\n' + boxLine('', P.border) + '\n');
188
235
  reasoningOpen = false;
189
236
  }
190
237
 
191
238
  function printStatus(line) {
239
+ ensureBoxOpen();
192
240
  if (reasoningOpen) closeReasoning();
193
241
  stopSpinner();
194
242
  const icon = statusIcon(line);
195
243
  const text = line.trimStart();
196
244
  const indent = /^[\s·•⌁]/.test(line) ? ' ' : ' ';
197
- process.stdout.write(indent + icon + ' ' + C.textSecondary(text) + '\n');
245
+ process.stdout.write(boxLine(indent + icon + ' ' + P.dim(text), P.border) + '\n');
198
246
  if (showThinking) spinner.start();
199
247
  }
200
248
 
@@ -206,7 +254,8 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
206
254
  }
207
255
 
208
256
  // ── SSE reader ─────────────────────────────────────────────────────────────
209
- if (showThinking) spinner.start();
257
+ if (showThinking) { ensureBoxOpen(); spinner.start(); }
258
+
210
259
  const decoder = new TextDecoder();
211
260
  let sseBuffer = '';
212
261
 
@@ -223,7 +272,8 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
223
272
  if (ev.status_delta !== undefined) {
224
273
  printStatus(ev.status_delta);
225
274
  } else if (ev.reason_delta !== undefined) {
226
- if (!reasoningOpen) openReasoning(); // re-open after STATUS closes it
275
+ ensureBoxOpen();
276
+ if (!reasoningOpen) openReasoning();
227
277
  writeReasonDelta(ev.reason_delta);
228
278
  } else if (ev.delta !== undefined) {
229
279
  if (reasoningOpen) closeReasoning();
@@ -237,56 +287,59 @@ export async function streamChat(cfg, messages, context = {}, opts = {}) {
237
287
  }
238
288
  }
239
289
 
240
- // ── Final render ───────────────────────────────────────────────────────────
290
+ // ── Final render ─────────────────────────────────────────────────────────────
241
291
  stopSpinner();
242
292
  if (reasoningOpen) closeReasoning();
293
+ ensureBoxOpen();
243
294
 
244
295
  const reply = finalResult?.reply ?? {};
245
296
  const replyType = reply.type ?? 'message';
246
297
  const meta = finalResult?._meta ?? {};
247
298
 
248
- // Save model to cfg for banner re-use
249
299
  if (meta.model && cfg) cfg._model = shortModel(meta.model);
250
300
 
251
- // Content separator (same as Copilot CLI visual spacing)
252
- process.stdout.write('\n');
301
+ // Blank separator before content
302
+ process.stdout.write(boxLine('', P.border) + '\n');
253
303
 
254
304
  if (replyType === 'message') {
255
- const text = reply.text || answerBuf || '(no response)';
256
- process.stdout.write(renderMarkdown(text).split('\n').map(l => ' ' + l).join('\n') + '\n');
305
+ const text = reply.text || answerBuf || '(no response)';
306
+ const rendered = renderMarkdown(text);
307
+ // Render each markdown line with │ prefix
308
+ for (const l of rendered.split('\n')) {
309
+ process.stdout.write(boxLine(' ' + l, P.border) + '\n');
310
+ }
257
311
 
258
312
  } else if (replyType === 'plan') {
259
- process.stdout.write(' ' + chalk.bold.white('Plan') + '\n');
260
- if (reply.summary) process.stdout.write(' ' + C.textSecondary(reply.summary) + '\n');
261
- process.stdout.write('\n');
313
+ process.stdout.write(boxLine(' ' + P.bold('Plan'), P.border) + '\n');
314
+ if (reply.summary) process.stdout.write(boxLine(' ' + P.dim(reply.summary), P.border) + '\n');
315
+ process.stdout.write(boxLine('', P.border) + '\n');
262
316
  (reply.steps || []).forEach((s, i) => {
263
- const risk = s.risk === 'high' ? C.statusErr(' ⚠ high risk') :
264
- s.risk === 'medium' ? C.statusWarn(' ⚡') : '';
265
- process.stdout.write(` ${C.textSecondary(String(i+1)+'.')} ${chalk.yellow(s.cmd || '')}${risk}\n`);
266
- if (s.purpose) process.stdout.write(C.textTertiary(` ${s.purpose}\n`));
317
+ const risk = s.risk === 'high' ? P.err(' ⚠ high risk') :
318
+ s.risk === 'medium' ? P.warn(' ⚡') : '';
319
+ process.stdout.write(boxLine(` ${P.dim(String(i+1)+'.')} ${P.warn(s.cmd || '')}${risk}`, P.border) + '\n');
320
+ if (s.purpose) process.stdout.write(boxLine(' ' + P.muted(s.purpose), P.border) + '\n');
267
321
  });
268
- process.stdout.write('\n' + C.textSecondary(' Reply ') + chalk.white('apply fix') + C.textSecondary(' to confirm.\n'));
322
+ process.stdout.write(boxLine('', P.border) + '\n');
323
+ process.stdout.write(boxLine(' ' + P.dim('Type ') + P.white('apply fix') + P.dim(' to confirm.'), P.border) + '\n');
269
324
 
270
325
  } else if (replyType === 'connect') {
271
326
  const dev = reply.device ?? {};
272
327
  process.stdout.write(
273
- ' ' + C.statusOk('🔌 ') +
274
- chalk.bold.white(`${dev.kind ?? '?'}#${dev.id ?? '?'}`) + ' ' +
275
- chalk.white(dev.name ?? '') + ' ' +
276
- C.textSecondary(`(${dev.host ?? ''})`) + '\n'
328
+ boxLine(' ' + P.ok('🔌 ') + P.bold(`${dev.kind ?? '?'}#${dev.id ?? '?'}`) +
329
+ ' ' + P.white(dev.name ?? '') + ' ' + P.dim(`(${dev.host ?? ''})`), P.border) + '\n'
277
330
  );
278
- if (reply.note) process.stdout.write(C.textSecondary(' ' + reply.note) + '\n');
331
+ if (reply.note) process.stdout.write(boxLine(' ' + P.dim(reply.note), P.border) + '\n');
279
332
  }
280
333
 
281
- // ── Footer matches Copilot CLI status bar format: model · user · ... ──────
282
- process.stdout.write('\n');
283
- const footParts = [];
284
- if (meta.model) footParts.push(shortModel(meta.model));
285
- if (meta.user) footParts.push(meta.user);
286
- if (meta.role) footParts.push(meta.role);
287
- if (footParts.length) {
288
- process.stdout.write(C.textTertiary(' ' + footParts.join(' · ')) + '\n');
334
+ // Blank + footer line inside box
335
+ process.stdout.write(boxLine('', P.border) + '\n');
336
+ const foot = [meta.model ? shortModel(meta.model) : null, meta.user, meta.role].filter(Boolean);
337
+ if (foot.length) {
338
+ process.stdout.write(boxLine(' ' + P.muted(foot.join(' · ')), P.border) + '\n');
289
339
  }
290
340
 
341
+ // Close the box
342
+ process.stdout.write(boxBotOpen(P.border) + '\n\n');
343
+
291
344
  return reply.text || answerBuf || '';
292
345
  }
package/src/layout.js ADDED
@@ -0,0 +1,69 @@
1
+ // ── Terminal width ─────────────────────────────────────────────────────────────
2
+ export function termCols() {
3
+ if (process.stdout.isTTY && process.stdout.columns > 0) return process.stdout.columns;
4
+ if (process.stderr.isTTY && process.stderr.columns > 0) return process.stderr.columns;
5
+ const env = parseInt(process.env.COLUMNS || process.env.TERM_WIDTH || '0');
6
+ if (env > 0) return env;
7
+ return 80;
8
+ }
9
+ export const cols = () => Math.max(40, termCols());
10
+ export const inner = () => cols() - 4; // usable width inside │ … │
11
+ export const onResize = (cb) => {
12
+ process.stdout.on('resize', cb);
13
+ process.stderr.on('resize', cb);
14
+ };
15
+
16
+ // ── ANSI-aware string helpers ─────────────────────────────────────────────────
17
+ const RE_ANSI = /\x1b\[[^m]*m/g;
18
+ export const stripAnsi = (s) => s.replace(RE_ANSI, '');
19
+ export const visLen = (s) => [...stripAnsi(s)].length; // Unicode-safe
20
+ export const padRight = (s, n) => s + ' '.repeat(Math.max(0, n - visLen(s)));
21
+
22
+ // ── Box-drawing helpers (rounded corners) ─────────────────────────────────────
23
+ // draw(color, char, n) — repeat char n times with color
24
+ const rep = (ch, n) => ch.repeat(Math.max(0, n));
25
+
26
+ /** Full top border: ╭─ [label] ──────────────╮ */
27
+ export function boxTop(label, borderFn) {
28
+ const w = cols();
29
+ const lbl = label ? ` ${label} ` : '';
30
+ const dash = rep('─', Math.max(0, w - 2 - lbl.length));
31
+ return borderFn(`╭${lbl.length ? '─' + lbl : ''}${dash}╮`);
32
+ }
33
+
34
+ /** Divider inside box: ├────────────────────────┤ */
35
+ export function boxMid(borderFn) {
36
+ return borderFn('├' + rep('─', cols() - 2) + '┤');
37
+ }
38
+
39
+ /** Full bottom border: ╰──────────────────────────╯ */
40
+ export function boxBot(borderFn) {
41
+ return borderFn('╰' + rep('─', cols() - 2) + '╯');
42
+ }
43
+
44
+ /** Open top border (no right cap — for streaming sections):
45
+ * ╭─ [label] ────────── */
46
+ export function boxTopOpen(label, borderFn) {
47
+ const w = cols();
48
+ const lbl = label ? ` ${label} ` : '';
49
+ const dash = rep('─', Math.max(0, w - 1 - lbl.length));
50
+ return borderFn(`╭─${lbl}${dash}`);
51
+ }
52
+
53
+ /** Open bottom border: ╰───────────────────────── */
54
+ export function boxBotOpen(borderFn) {
55
+ return borderFn('╰' + rep('─', cols() - 1));
56
+ }
57
+
58
+ /** One content row with left border and padded right border:
59
+ * │ [content] │ */
60
+ export function boxRow(content, borderFn) {
61
+ const w = cols();
62
+ const pad = Math.max(0, w - 4 - visLen(content));
63
+ return borderFn('│') + ' ' + content + ' '.repeat(pad) + ' ' + borderFn('│');
64
+ }
65
+
66
+ /** Left-border only line (for streaming): │ [content] */
67
+ export function boxLine(content, borderFn) {
68
+ return borderFn('│') + ' ' + content;
69
+ }