icopilot 2.3.3 → 2.3.5
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/CHANGELOG.md +25 -0
- package/dist/commands/slash.js +7 -4
- package/dist/modes/autopilot.js +31 -6
- package/dist/modes/interactive.js +4 -1
- package/dist/modes/tui.js +115 -26
- package/dist/tools/file-ops.js +23 -7
- package/dist/ui/prompt.js +39 -3
- package/dist/ui/select.js +82 -0
- package/dist/ui/spinner.js +50 -0
- package/dist/ui/theme.js +19 -33
- package/dist/ui/tui-layout.js +121 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [2.3.5] — 2026-06-30
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Release workflow** — bumped `package.json` version to `2.3.5` to match the `v2.3.5` git tag; the previous publish attempt was rejected by npm because version `2.3.4` was already published
|
|
14
|
+
|
|
15
|
+
## [2.3.4] — 2026-06-29
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **Screen stacking / infinite box loop** — input box borders no longer stack on every submission; `drawBoxTop()` now erases the previous bottom border with ANSI cursor-up + clear-line sequences before drawing a fresh frame
|
|
19
|
+
- **`/m` slash command tokenizer** — commands like `/model`, `/provider set <name>`, and `/plan` were truncated to their first character; replaced `indexOf`-based slicing with `split(/\s+/)` so the full command name is always parsed correctly
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- **Splash banner** — replaced pixel-art block logo with the standard figlet-style ASCII logo rendered in bold cyan (`#58A6FF`); status indicators updated to `● Provider: GitHub Models (default: …)` and `● Session: Active (…)` with a dim divider
|
|
23
|
+
- **Hotkey dock** — updated footer to `Ctrl+C Exit │ Ctrl+R Clear History │ Tab Autocomplete`
|
|
24
|
+
- **Terminal resize** — `process.stdout.on('resize')` added alongside `SIGWINCH` for portability (e.g. Windows ConPTY); resize also resets the pending-erase counter to avoid misaligned cursors
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- **`src/ui/spinner.ts`** — `Spinner` class with `start(label)`, `update(label)`, `stop(success?)` using braille frames at 80 ms; degrades gracefully to plain text in non-TTY environments
|
|
28
|
+
- **`src/ui/select.ts`** — `selectMenu(choices, initial?)` arrow-key `❯` selection menu; auto-selects the first item in non-TTY mode
|
|
29
|
+
- **Autopilot progress UX** — each step now shows a live spinner that resolves to `✔` or `✖`; when `requireApproval` is enabled a between-step `selectMenu` asks "Continue / Abort" instead of running blindly
|
|
30
|
+
- **Plan-mode file confirmations** — `confirm()` Y/N prompts replaced with `select()` arrow-key menus (`❯ Apply changes / Skip`) for all file write proposals
|
|
31
|
+
- **`@file` mention hint** — ghost text shows `file/path` after a bare `@` token in the input field
|
|
32
|
+
|
|
8
33
|
## [2.1.0] — 2026-06-28
|
|
9
34
|
|
|
10
35
|
### Added — v2.1 Competitive Parity
|
package/dist/commands/slash.js
CHANGED
|
@@ -284,10 +284,13 @@ export async function handleSlash(line, ctx) {
|
|
|
284
284
|
}
|
|
285
285
|
return done(false, modePrefix.forwardInput, modePrefix.turnMode ?? null);
|
|
286
286
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const
|
|
287
|
+
// Tokenize: split on whitespace so "/model gpt-4o" → ["model","gpt-4o"]
|
|
288
|
+
// This avoids premature character slicing that truncated commands like
|
|
289
|
+
// "/model" to "/m" when multi-byte or invisible whitespace was present.
|
|
290
|
+
const tokens = trimmed.slice(1).split(/\s+/);
|
|
291
|
+
const cmd = tokens[0] ?? '';
|
|
292
|
+
const rest = tokens.slice(1);
|
|
293
|
+
const arg = rest.join(' ');
|
|
291
294
|
const s = ctx.session;
|
|
292
295
|
const roleManager = getRoleManager(s.state.cwd);
|
|
293
296
|
const normalizedCommand = cmd.toLowerCase();
|
package/dist/modes/autopilot.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Session } from '../session/session.js';
|
|
2
2
|
import { hookManager } from '../hooks/lifecycle.js';
|
|
3
3
|
import { theme } from '../ui/theme.js';
|
|
4
|
+
import { Spinner } from '../ui/spinner.js';
|
|
5
|
+
import { selectMenu } from '../ui/select.js';
|
|
4
6
|
import { runTurn } from './turn.js';
|
|
5
7
|
export const AUTOPILOT_MAX_STEPS = 10;
|
|
6
8
|
const AUTOPILOT_REQUIRE_APPROVAL_DEFAULT = true;
|
|
@@ -86,17 +88,40 @@ export async function runAutopilot(goal, opts = {}) {
|
|
|
86
88
|
session.setAutopilotEnabled(false);
|
|
87
89
|
session.setMode('ask');
|
|
88
90
|
session.setSystemPrompt(buildAutopilotSystemPrompt(normalizedGoal));
|
|
91
|
+
const requireApproval = opts.requireApproval ?? AUTOPILOT_REQUIRE_APPROVAL_DEFAULT;
|
|
89
92
|
try {
|
|
90
93
|
for (let step = 1; step <= maxSteps; step++) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
const spinner = new Spinner();
|
|
95
|
+
spinner.start(`Step ${step} of ${maxSteps} …`);
|
|
96
|
+
let stepError;
|
|
97
|
+
try {
|
|
98
|
+
await runTurn({
|
|
99
|
+
session,
|
|
100
|
+
userInput: buildAutopilotTurnPrompt(normalizedGoal, step, maxSteps),
|
|
101
|
+
signal,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
stepError = err;
|
|
106
|
+
}
|
|
107
|
+
const success = stepError == null;
|
|
108
|
+
spinner.stop(success);
|
|
109
|
+
if (!success) {
|
|
110
|
+
process.stdout.write(theme.err(`\n✖ step ${step} failed: ${stepError?.message ?? stepError}\n`));
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
97
113
|
if (isAutopilotComplete(findLastAssistantMessage(session.state.messages))) {
|
|
98
114
|
return session;
|
|
99
115
|
}
|
|
116
|
+
// When requireApproval is on, pause between steps so the user can review.
|
|
117
|
+
if (requireApproval && step < maxSteps) {
|
|
118
|
+
process.stdout.write('\n');
|
|
119
|
+
const choice = await selectMenu(['Continue to next step', 'Abort autopilot']);
|
|
120
|
+
if (choice !== 0) {
|
|
121
|
+
process.stdout.write(theme.dim('\nautopilot aborted by user.\n'));
|
|
122
|
+
return session;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
100
125
|
}
|
|
101
126
|
process.stdout.write(theme.warn(`\n⚠ autopilot stopped after ${maxSteps} steps.\n`));
|
|
102
127
|
return session;
|
|
@@ -3,7 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { Session } from '../session/session.js';
|
|
5
5
|
import { theme, banner } from '../ui/theme.js';
|
|
6
|
-
import { createPrompt, prefix } from '../ui/prompt.js';
|
|
6
|
+
import { createPrompt, prefix, invalidateBoxBottom } from '../ui/prompt.js';
|
|
7
7
|
import { handleSlash } from '../commands/slash.js';
|
|
8
8
|
import { loadAliases, resolveAlias } from '../commands/alias-cmd.js';
|
|
9
9
|
import { MetricsCollector } from '../commands/metrics-cmd.js';
|
|
@@ -83,6 +83,9 @@ export async function runInteractive(initialMode = 'ask', opts = {}) {
|
|
|
83
83
|
const resolvedLine = resolveAlias(next.line, loadAliases()) ?? next.line;
|
|
84
84
|
currentAbort = new AbortController();
|
|
85
85
|
try {
|
|
86
|
+
// Any output printed during processing must not be erased by the
|
|
87
|
+
// next input-box render, so invalidate the pending-erase counter.
|
|
88
|
+
invalidateBoxBottom();
|
|
86
89
|
if (next.scheduled) {
|
|
87
90
|
process.stdout.write(theme.dim(`\n[schedule] ${next.line}\n`));
|
|
88
91
|
}
|
package/dist/modes/tui.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import readline from 'node:readline';
|
|
2
2
|
import { Writable } from 'node:stream';
|
|
3
|
+
import simpleGit from 'simple-git';
|
|
3
4
|
import { loadAliases, resolveAlias } from '../commands/alias-cmd.js';
|
|
4
5
|
import { MetricsCollector } from '../commands/metrics-cmd.js';
|
|
5
6
|
import { handleSlash } from '../commands/slash.js';
|
|
6
7
|
import { Session } from '../session/session.js';
|
|
7
8
|
import { altScreenEnter, altScreenExit, clear, hideCursor, showCursor, size, } from '../ui/screen.js';
|
|
9
|
+
import { WORKSPACE_TABS, renderTabBar, renderHero, renderStatusDock, magentaSeparator, renderFooter, } from '../ui/tui-layout.js';
|
|
10
|
+
import { safeUnicode } from '../ui/theme.js';
|
|
8
11
|
import { handlePostTurnContextBudget } from './auto-compact.js';
|
|
9
12
|
import { backgroundTaskManager } from './background.js';
|
|
10
13
|
import { runAutopilot } from './autopilot.js';
|
|
@@ -12,6 +15,7 @@ import { runTurn } from './turn.js';
|
|
|
12
15
|
import { hookManager } from '../hooks/lifecycle.js';
|
|
13
16
|
const VERSION = '1.3.0';
|
|
14
17
|
const FRAME_MS = 33;
|
|
18
|
+
const CREDIT_BUDGET = 1000;
|
|
15
19
|
export async function runTui(initialMode = 'ask', opts = {}) {
|
|
16
20
|
const session = new Session({ mode: initialMode });
|
|
17
21
|
await session.initializeGitContext();
|
|
@@ -39,7 +43,33 @@ export async function runTui(initialMode = 'ask', opts = {}) {
|
|
|
39
43
|
let cleaned = false;
|
|
40
44
|
let frame;
|
|
41
45
|
let currentAbort = null;
|
|
46
|
+
let activeTab = 0;
|
|
47
|
+
let gitBranch = '';
|
|
48
|
+
let branchInFlight = false;
|
|
49
|
+
let lastCwd = session.state.cwd;
|
|
42
50
|
const pendingInputs = [];
|
|
51
|
+
// Resolve the current git branch once (and on cwd change) for the status dock.
|
|
52
|
+
// A simple in-flight guard avoids overlapping lookups racing to set gitBranch.
|
|
53
|
+
const refreshBranch = () => {
|
|
54
|
+
if (branchInFlight)
|
|
55
|
+
return;
|
|
56
|
+
branchInFlight = true;
|
|
57
|
+
void (async () => {
|
|
58
|
+
try {
|
|
59
|
+
const git = simpleGit(session.state.cwd);
|
|
60
|
+
if (await git.checkIsRepo()) {
|
|
61
|
+
gitBranch = (await git.revparse(['--abbrev-ref', 'HEAD'])).trim();
|
|
62
|
+
markDirty();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
/* not a git repo — leave the branch blank */
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
branchInFlight = false;
|
|
70
|
+
}
|
|
71
|
+
})();
|
|
72
|
+
};
|
|
43
73
|
const rl = readline.createInterface({
|
|
44
74
|
input: process.stdin,
|
|
45
75
|
output: silentOutput,
|
|
@@ -58,7 +88,7 @@ export async function runTui(initialMode = 'ask', opts = {}) {
|
|
|
58
88
|
process.stdout.write = originalWrite;
|
|
59
89
|
process.stdout.off('resize', onResize);
|
|
60
90
|
process.off('SIGINT', onSigint);
|
|
61
|
-
process.stdin.off('keypress',
|
|
91
|
+
process.stdin.off('keypress', onKeypress);
|
|
62
92
|
if (frame)
|
|
63
93
|
clearInterval(frame);
|
|
64
94
|
rl.close();
|
|
@@ -81,38 +111,69 @@ export async function runTui(initialMode = 'ask', opts = {}) {
|
|
|
81
111
|
return;
|
|
82
112
|
dirty = false;
|
|
83
113
|
const { rows, cols } = size();
|
|
114
|
+
// Refresh branch if cwd changed since the last render.
|
|
115
|
+
if (session.state.cwd !== lastCwd) {
|
|
116
|
+
lastCwd = session.state.cwd;
|
|
117
|
+
refreshBranch();
|
|
118
|
+
}
|
|
119
|
+
// Absolute row anchors (1-indexed for ANSI cursor positioning):
|
|
120
|
+
// row 1 → tabbed navigation header
|
|
121
|
+
// rows 2..dockRow-1 → hero canvas + conversation timeline
|
|
122
|
+
// dockRow → status dock (locked 3 rows above the bottom)
|
|
123
|
+
// dockRow + 1 → magenta boundary line
|
|
124
|
+
// dockRow + 2 → input dock
|
|
125
|
+
// rows (bottom) → persistent footer
|
|
126
|
+
const tabRow = 1;
|
|
84
127
|
const chatTop = 2;
|
|
85
|
-
|
|
128
|
+
// The status dock is locked exactly 3 rows above the absolute bottom, so the
|
|
129
|
+
// four bottom rows are: dock, magenta separator, input dock, footer.
|
|
130
|
+
const dockRow = Math.max(chatTop, rows - 3);
|
|
131
|
+
const separatorRow = Math.min(dockRow + 1, rows);
|
|
132
|
+
const inputRow = Math.min(dockRow + 2, rows);
|
|
133
|
+
const footerRow = Math.min(dockRow + 3, rows);
|
|
134
|
+
const chatBottom = dockRow - 1;
|
|
86
135
|
const chatHeight = Math.max(1, chatBottom - chatTop + 1);
|
|
87
136
|
writeRaw('\x1b[2J\x1b[H');
|
|
88
|
-
|
|
89
|
-
|
|
137
|
+
// Row 0 — tabbed navigation header.
|
|
138
|
+
writeRaw(`\x1b[${tabRow};1H`);
|
|
139
|
+
writeRaw(renderTabBar(activeTab, cols));
|
|
140
|
+
// Conversation timeline (hero canvas shown only while it is empty).
|
|
141
|
+
const showHero = !chat.trim();
|
|
142
|
+
const heroLines = showHero ? heroCanvas(session, cols) : [];
|
|
143
|
+
const lines = showHero ? heroLines : wrapLines(chat.trimEnd(), Math.max(1, cols));
|
|
90
144
|
const visible = lines.slice(-chatHeight);
|
|
91
145
|
for (let i = 0; i < chatHeight; i++) {
|
|
92
146
|
writeRaw(`\x1b[${chatTop + i};1H`);
|
|
93
|
-
|
|
147
|
+
// Hero lines are already padded to the full width (and carry ANSI), so
|
|
148
|
+
// they are written verbatim; plain chat lines are padded here.
|
|
149
|
+
writeRaw(showHero ? visible[i] || '' : padToCols(visible[i] || '', cols));
|
|
94
150
|
}
|
|
95
|
-
//
|
|
96
|
-
writeRaw(`\x1b[${
|
|
151
|
+
// Status dock — left: cwd + branch, right: live consumption metrics.
|
|
152
|
+
writeRaw(`\x1b[${dockRow};1H`);
|
|
153
|
+
writeRaw(statusDock(session, gitBranch, cols, busy));
|
|
154
|
+
// Magenta boundary line.
|
|
155
|
+
writeRaw(`\x1b[${separatorRow};1H`);
|
|
97
156
|
if (busy) {
|
|
98
157
|
const thinkLabel = ' ◆ Copilot is thinking… ';
|
|
99
158
|
const sideLen = Math.max(0, Math.floor((cols - thinkLabel.length) / 2));
|
|
100
|
-
writeRaw('
|
|
159
|
+
writeRaw('\x1b[35m' +
|
|
160
|
+
'─'.repeat(sideLen) +
|
|
101
161
|
thinkLabel +
|
|
102
|
-
'─'.repeat(Math.max(0, cols - sideLen - thinkLabel.length))
|
|
162
|
+
'─'.repeat(Math.max(0, cols - sideLen - thinkLabel.length)) +
|
|
163
|
+
'\x1b[0m');
|
|
103
164
|
}
|
|
104
165
|
else {
|
|
105
|
-
writeRaw(
|
|
166
|
+
writeRaw(magentaSeparator(cols));
|
|
106
167
|
}
|
|
107
|
-
|
|
168
|
+
// Input dock — a single dedicated line bracket below the separator.
|
|
169
|
+
writeRaw(`\x1b[${inputRow};1H`);
|
|
108
170
|
const promptIcon = busy ? '\x1b[33m◆\x1b[0m' : '\x1b[32m❯\x1b[0m';
|
|
109
|
-
const
|
|
110
|
-
writeRaw(
|
|
111
|
-
writeRaw(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
writeRaw(pad(hint, cols));
|
|
171
|
+
const buffer = (rl.line || '').replace(/\t/g, '');
|
|
172
|
+
writeRaw('\x1b[2K');
|
|
173
|
+
writeRaw(padToCols(`${promptIcon} ${buffer}`, cols));
|
|
174
|
+
// Absolute bottom row — persistent footer legend.
|
|
175
|
+
writeRaw(`\x1b[${footerRow};1H`);
|
|
176
|
+
writeRaw(renderFooter(cols));
|
|
116
177
|
};
|
|
117
178
|
const appendCaptured = (chunk) => {
|
|
118
179
|
const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
|
|
@@ -227,11 +288,22 @@ export async function runTui(initialMode = 'ask', opts = {}) {
|
|
|
227
288
|
if (running)
|
|
228
289
|
cleanup();
|
|
229
290
|
});
|
|
230
|
-
|
|
291
|
+
const onKeypress = (_ch, key) => {
|
|
292
|
+
// Tab / Shift+Tab cycles the workspace navigation tabs.
|
|
293
|
+
if (key?.name === 'tab') {
|
|
294
|
+
const delta = key.shift ? -1 : 1;
|
|
295
|
+
activeTab = (activeTab + delta + WORKSPACE_TABS.length) % WORKSPACE_TABS.length;
|
|
296
|
+
}
|
|
297
|
+
markDirty();
|
|
298
|
+
};
|
|
299
|
+
process.stdin.on('keypress', onKeypress);
|
|
231
300
|
altScreenEnter();
|
|
232
301
|
hideCursor();
|
|
233
302
|
clear();
|
|
234
|
-
|
|
303
|
+
// An empty timeline makes the renderer show the hero/branding canvas (with its
|
|
304
|
+
// tip bulletins) until the first turn produces conversation output.
|
|
305
|
+
chat = '';
|
|
306
|
+
refreshBranch();
|
|
235
307
|
frame = setInterval(render, FRAME_MS);
|
|
236
308
|
try {
|
|
237
309
|
await new Promise((resolve) => {
|
|
@@ -254,12 +326,29 @@ export async function runTui(initialMode = 'ask', opts = {}) {
|
|
|
254
326
|
});
|
|
255
327
|
}
|
|
256
328
|
}
|
|
257
|
-
function
|
|
258
|
-
const
|
|
329
|
+
function heroCanvas(session, cols) {
|
|
330
|
+
const modelShort = session.state.model.replace('openai/', '').replace('github/', '');
|
|
331
|
+
return renderHero({
|
|
332
|
+
version: VERSION,
|
|
333
|
+
provider: 'GitHub Models',
|
|
334
|
+
experimental: '/experimental [Active]',
|
|
335
|
+
tips: [
|
|
336
|
+
'Tip: Run /doctor to diagnose your environment configuration and tool availability.',
|
|
337
|
+
'Tool access is determined by your configured role and policy settings.',
|
|
338
|
+
`Active model: ${modelShort}. Type /help for commands or @ to target files.`,
|
|
339
|
+
],
|
|
340
|
+
}, cols);
|
|
341
|
+
}
|
|
342
|
+
function statusDock(session, branch, cols, busy) {
|
|
259
343
|
const modelShort = session.state.model.replace('openai/', '').replace('github/', '');
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
344
|
+
// Nerd-font git branch glyph (U+F02A2) when supported, plain label otherwise.
|
|
345
|
+
const branchIcon = safeUnicode ? '\u{F02A2} ' : 'git:';
|
|
346
|
+
const branchLabel = branch ? ` [${branchIcon}${branch}]` : '';
|
|
347
|
+
const left = `${session.state.cwd}${branchLabel}`;
|
|
348
|
+
const used = Math.min(CREDIT_BUDGET, Math.ceil(session.tokenUsage() / 1000));
|
|
349
|
+
const working = busy ? ' \x1b[33m◆ WORKING\x1b[0m │ ' : '';
|
|
350
|
+
const right = `${working}Usage: ${used}/${CREDIT_BUDGET} credits │ ${modelShort}`;
|
|
351
|
+
return renderStatusDock(left, right, cols);
|
|
263
352
|
}
|
|
264
353
|
function wrapLines(text, width) {
|
|
265
354
|
const out = [];
|
|
@@ -271,7 +360,7 @@ function wrapLines(text, width) {
|
|
|
271
360
|
}
|
|
272
361
|
return out.length ? out : [''];
|
|
273
362
|
}
|
|
274
|
-
function
|
|
363
|
+
function padToCols(text, cols) {
|
|
275
364
|
const clipped = text.length > cols ? text.slice(0, cols) : text;
|
|
276
365
|
return clipped + ' '.repeat(Math.max(0, cols - clipped.length));
|
|
277
366
|
}
|
package/dist/tools/file-ops.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { createPatch } from 'diff';
|
|
4
|
-
import {
|
|
4
|
+
import { select } from '@inquirer/prompts';
|
|
5
5
|
import { config } from '../config.js';
|
|
6
6
|
import { theme } from '../ui/theme.js';
|
|
7
7
|
import { formatAutoCheckResult, runAutoLint } from './auto-check.js';
|
|
@@ -35,8 +35,12 @@ export async function proposeWrite(relPath, newContent) {
|
|
|
35
35
|
const remembered = toolMemory.isWriteRemembered(abs);
|
|
36
36
|
const ok = config.autoApprove ||
|
|
37
37
|
remembered ||
|
|
38
|
-
(await
|
|
38
|
+
(await select({
|
|
39
39
|
message: exists ? 'Apply this patch?' : 'Create this new file?',
|
|
40
|
+
choices: [
|
|
41
|
+
{ name: '❯ Apply changes', value: true },
|
|
42
|
+
{ name: ' Skip', value: false },
|
|
43
|
+
],
|
|
40
44
|
default: false,
|
|
41
45
|
}).catch(() => false));
|
|
42
46
|
if (!ok) {
|
|
@@ -45,10 +49,14 @@ export async function proposeWrite(relPath, newContent) {
|
|
|
45
49
|
return { wrote: false, path: abs, bytes: 0 };
|
|
46
50
|
}
|
|
47
51
|
if (!config.autoApprove && !remembered) {
|
|
48
|
-
const remember = await
|
|
52
|
+
const remember = (await select({
|
|
49
53
|
message: 'Remember this write path for the session?',
|
|
54
|
+
choices: [
|
|
55
|
+
{ name: ' Yes — skip confirmation next time', value: true },
|
|
56
|
+
{ name: ' No', value: false },
|
|
57
|
+
],
|
|
50
58
|
default: false,
|
|
51
|
-
}).catch(() => false);
|
|
59
|
+
}).catch(() => false));
|
|
52
60
|
if (remember)
|
|
53
61
|
toolMemory.rememberWrite(abs);
|
|
54
62
|
}
|
|
@@ -100,8 +108,12 @@ export async function proposeWriteBatch(items) {
|
|
|
100
108
|
const remembered = prepared.every((item) => toolMemory.isWriteRemembered(item.abs));
|
|
101
109
|
const ok = config.autoApprove ||
|
|
102
110
|
remembered ||
|
|
103
|
-
(await
|
|
111
|
+
(await select({
|
|
104
112
|
message: 'Apply all patches?',
|
|
113
|
+
choices: [
|
|
114
|
+
{ name: '❯ Apply all changes', value: true },
|
|
115
|
+
{ name: ' Skip all', value: false },
|
|
116
|
+
],
|
|
105
117
|
default: false,
|
|
106
118
|
}).catch(() => false));
|
|
107
119
|
if (!ok) {
|
|
@@ -113,10 +125,14 @@ export async function proposeWriteBatch(items) {
|
|
|
113
125
|
};
|
|
114
126
|
}
|
|
115
127
|
if (!config.autoApprove && !remembered) {
|
|
116
|
-
const remember = await
|
|
128
|
+
const remember = (await select({
|
|
117
129
|
message: 'Remember these write paths for the session?',
|
|
130
|
+
choices: [
|
|
131
|
+
{ name: ' Yes — skip confirmation next time', value: true },
|
|
132
|
+
{ name: ' No', value: false },
|
|
133
|
+
],
|
|
118
134
|
default: false,
|
|
119
|
-
}).catch(() => false);
|
|
135
|
+
}).catch(() => false));
|
|
120
136
|
if (remember)
|
|
121
137
|
prepared.forEach((item) => toolMemory.rememberWrite(item.abs));
|
|
122
138
|
}
|
package/dist/ui/prompt.js
CHANGED
|
@@ -2,6 +2,14 @@ import readline from 'node:readline';
|
|
|
2
2
|
import { theme, safeUnicode } from './theme.js';
|
|
3
3
|
import { defaultContext } from '../util/completion.js';
|
|
4
4
|
import { attachKeybindings, applyKeybindingConfig, } from '../util/keybindings.js';
|
|
5
|
+
/**
|
|
6
|
+
* Call this whenever content is written to stdout between two read() calls
|
|
7
|
+
* (e.g. streamed LLM output) so we don't accidentally erase that content
|
|
8
|
+
* when the next input box is rendered.
|
|
9
|
+
*/
|
|
10
|
+
export function invalidateBoxBottom() {
|
|
11
|
+
pendingEraseLines = 0;
|
|
12
|
+
}
|
|
5
13
|
// ─── Slash command completer ───────────────────────────────────────────────
|
|
6
14
|
function slashCompleter(line) {
|
|
7
15
|
const ctx = defaultContext();
|
|
@@ -26,7 +34,18 @@ const PLACEHOLDER = 'Enter @ to mention files or / for commands...';
|
|
|
26
34
|
function boxWidth() {
|
|
27
35
|
return Math.max(60, (process.stdout.columns || 80) - 6);
|
|
28
36
|
}
|
|
37
|
+
// Track lines printed by drawBoxBottom() so the next read() can erase them
|
|
38
|
+
// before drawing a fresh top border (prevents infinite box stacking).
|
|
39
|
+
let pendingEraseLines = 0;
|
|
29
40
|
function drawBoxTop() {
|
|
41
|
+
if (pendingEraseLines > 0 && process.stdout.isTTY) {
|
|
42
|
+
// Erase the bottom border printed by the previous submission:
|
|
43
|
+
// drawBoxBottom writes "\n<border>\n" = 2 extra lines below the readline line.
|
|
44
|
+
for (let i = 0; i < pendingEraseLines; i++) {
|
|
45
|
+
process.stdout.write('\x1b[1A\x1b[2K'); // cursor up + clear line
|
|
46
|
+
}
|
|
47
|
+
pendingEraseLines = 0;
|
|
48
|
+
}
|
|
30
49
|
const w = boxWidth();
|
|
31
50
|
const colorEnabled = theme.dim('') !== ''; // cheap color-enabled check
|
|
32
51
|
const line = colorEnabled ? theme.dim(` ╭${'─'.repeat(w)}╮`) : ` ╭${'─'.repeat(w)}╮`;
|
|
@@ -36,11 +55,15 @@ function drawBoxBottom() {
|
|
|
36
55
|
const w = boxWidth();
|
|
37
56
|
const line = theme.dim(` ╰${'─'.repeat(w)}╯`);
|
|
38
57
|
process.stdout.write('\n' + line + '\n');
|
|
58
|
+
// Two lines were added below the readline line: the blank line (\n) and the
|
|
59
|
+
// border line itself. The trailing \n moves the cursor one further line down,
|
|
60
|
+
// so we need to go back 2 lines on the next drawBoxTop() call.
|
|
61
|
+
pendingEraseLines = 2;
|
|
39
62
|
}
|
|
40
63
|
// ─── Persistent footer (scroll-region docked) ──────────────────────────────
|
|
41
64
|
const FOOTER_KEYS = safeUnicode
|
|
42
|
-
? ' Ctrl+C Exit │
|
|
43
|
-
: ' Ctrl+C Exit |
|
|
65
|
+
? ' Ctrl+C Exit │ Ctrl+R Clear History │ Tab Autocomplete'
|
|
66
|
+
: ' Ctrl+C Exit | Ctrl+R Clear History | Tab Autocomplete';
|
|
44
67
|
let footerInstalled = false;
|
|
45
68
|
function footerLine(cols) {
|
|
46
69
|
const text = FOOTER_KEYS;
|
|
@@ -83,10 +106,16 @@ export function createPrompt(keybindingMode) {
|
|
|
83
106
|
if (isTTY)
|
|
84
107
|
installFooter();
|
|
85
108
|
const onResize = () => {
|
|
86
|
-
if (isTTY && footerInstalled)
|
|
109
|
+
if (isTTY && footerInstalled) {
|
|
110
|
+
pendingEraseLines = 0; // can't reliably erase across a resize
|
|
87
111
|
installFooter();
|
|
112
|
+
}
|
|
88
113
|
};
|
|
89
114
|
process.on('SIGWINCH', onResize);
|
|
115
|
+
// Also listen on stdout directly for environments that emit 'resize'
|
|
116
|
+
// instead of (or in addition to) SIGWINCH (e.g. Windows ConPTY).
|
|
117
|
+
if (isTTY)
|
|
118
|
+
process.stdout.on('resize', onResize);
|
|
90
119
|
// ── Ghost text helpers ────────────────────────────────────────────────
|
|
91
120
|
const clearGhost = () => {
|
|
92
121
|
if (!activeGhost || !isTTY)
|
|
@@ -138,6 +167,12 @@ export function createPrompt(keybindingMode) {
|
|
|
138
167
|
drawGhost(PLACEHOLDER);
|
|
139
168
|
return;
|
|
140
169
|
}
|
|
170
|
+
// Hint after a partial @mention: show "→ @<token>" in dim text.
|
|
171
|
+
const atMatch = line.match(/@([\w./\\-]*)$/);
|
|
172
|
+
if (atMatch) {
|
|
173
|
+
drawGhost(atMatch[0].length > 1 ? '' : 'file/path');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
141
176
|
if (!/^\/\w/.test(line))
|
|
142
177
|
return;
|
|
143
178
|
const [hits] = slashCompleter(line);
|
|
@@ -178,6 +213,7 @@ export function createPrompt(keybindingMode) {
|
|
|
178
213
|
removeFooter();
|
|
179
214
|
process.off('SIGWINCH', onResize);
|
|
180
215
|
if (isTTY) {
|
|
216
|
+
process.stdout.off('resize', onResize);
|
|
181
217
|
process.stdin.removeListener('keypress', onKeypressClear);
|
|
182
218
|
process.stdin.removeListener('keypress', onKeypressDraw);
|
|
183
219
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import { theme, safeUnicode } from './theme.js';
|
|
3
|
+
const CURSOR = safeUnicode ? '❯' : '>';
|
|
4
|
+
const BLANK = ' ';
|
|
5
|
+
/**
|
|
6
|
+
* Render an interactive arrow-key selection menu.
|
|
7
|
+
*
|
|
8
|
+
* @param choices List of option labels to display.
|
|
9
|
+
* @param initial Initially selected index (default 0).
|
|
10
|
+
* @returns Resolves with the index of the chosen option, or -1 if
|
|
11
|
+
* the user pressed Escape / Ctrl-C.
|
|
12
|
+
*
|
|
13
|
+
* Example:
|
|
14
|
+
* ❯ Run this command
|
|
15
|
+
* Revise command instructions
|
|
16
|
+
* Abort operation
|
|
17
|
+
*/
|
|
18
|
+
export async function selectMenu(choices, initial = 0) {
|
|
19
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
20
|
+
// Non-interactive: auto-select the first option.
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
let selected = Math.max(0, Math.min(initial, choices.length - 1));
|
|
25
|
+
readline.emitKeypressEvents(process.stdin);
|
|
26
|
+
const wasRaw = process.stdin.isRaw ?? false;
|
|
27
|
+
process.stdin.setRawMode(true);
|
|
28
|
+
const render = () => {
|
|
29
|
+
// Erase all previously drawn lines.
|
|
30
|
+
for (let i = 0; i < choices.length; i++) {
|
|
31
|
+
process.stdout.write('\x1b[2K\r');
|
|
32
|
+
if (i < choices.length - 1)
|
|
33
|
+
process.stdout.write('\x1b[1A');
|
|
34
|
+
}
|
|
35
|
+
// Re-draw.
|
|
36
|
+
for (let i = 0; i < choices.length; i++) {
|
|
37
|
+
const active = i === selected;
|
|
38
|
+
const cursor = active ? theme.brand(CURSOR) : BLANK;
|
|
39
|
+
const label = active ? choices[i] : theme.dim(choices[i] ?? '');
|
|
40
|
+
process.stdout.write(` ${cursor} ${label}`);
|
|
41
|
+
if (i < choices.length - 1)
|
|
42
|
+
process.stdout.write('\n');
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
// Draw initial menu.
|
|
46
|
+
process.stdout.write('\n');
|
|
47
|
+
for (const choice of choices) {
|
|
48
|
+
process.stdout.write(` ${BLANK} ${theme.dim(choice)}\n`);
|
|
49
|
+
}
|
|
50
|
+
// Move cursor back up to rewrite from the first line.
|
|
51
|
+
for (let i = 0; i < choices.length; i++) {
|
|
52
|
+
process.stdout.write('\x1b[1A');
|
|
53
|
+
}
|
|
54
|
+
render();
|
|
55
|
+
const onKeypress = (_ch, key) => {
|
|
56
|
+
if (!key)
|
|
57
|
+
return;
|
|
58
|
+
if (key.name === 'up') {
|
|
59
|
+
selected = (selected - 1 + choices.length) % choices.length;
|
|
60
|
+
render();
|
|
61
|
+
}
|
|
62
|
+
else if (key.name === 'down') {
|
|
63
|
+
selected = (selected + 1) % choices.length;
|
|
64
|
+
render();
|
|
65
|
+
}
|
|
66
|
+
else if (key.name === 'return' || key.name === 'enter') {
|
|
67
|
+
cleanup(selected);
|
|
68
|
+
}
|
|
69
|
+
else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
|
70
|
+
cleanup(-1);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const cleanup = (result) => {
|
|
74
|
+
process.stdin.removeListener('keypress', onKeypress);
|
|
75
|
+
if (!wasRaw)
|
|
76
|
+
process.stdin.setRawMode(false);
|
|
77
|
+
process.stdout.write('\n');
|
|
78
|
+
resolve(result);
|
|
79
|
+
};
|
|
80
|
+
process.stdin.on('keypress', onKeypress);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { theme } from './theme.js';
|
|
2
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
3
|
+
const INTERVAL_MS = 80;
|
|
4
|
+
/**
|
|
5
|
+
* A simple TTY spinner that renders a label next to a rotating braille frame.
|
|
6
|
+
* Falls back to a plain text prefix when the terminal is not a TTY or when
|
|
7
|
+
* Unicode / colours are disabled.
|
|
8
|
+
*/
|
|
9
|
+
export class Spinner {
|
|
10
|
+
timer = null;
|
|
11
|
+
frame = 0;
|
|
12
|
+
label = '';
|
|
13
|
+
isTTY;
|
|
14
|
+
constructor() {
|
|
15
|
+
this.isTTY = Boolean(process.stdout.isTTY);
|
|
16
|
+
}
|
|
17
|
+
start(label) {
|
|
18
|
+
this.label = label;
|
|
19
|
+
this.frame = 0;
|
|
20
|
+
if (!this.isTTY) {
|
|
21
|
+
process.stdout.write(` … ${label}\n`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
this.render();
|
|
25
|
+
this.timer = setInterval(() => this.render(), INTERVAL_MS);
|
|
26
|
+
}
|
|
27
|
+
update(label) {
|
|
28
|
+
this.label = label;
|
|
29
|
+
if (!this.isTTY) {
|
|
30
|
+
process.stdout.write(` … ${label}\n`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
stop(success = true) {
|
|
34
|
+
if (this.timer !== null) {
|
|
35
|
+
clearInterval(this.timer);
|
|
36
|
+
this.timer = null;
|
|
37
|
+
}
|
|
38
|
+
if (this.isTTY) {
|
|
39
|
+
// Overwrite the spinner line with a final status icon + label.
|
|
40
|
+
process.stdout.write('\r\x1b[2K');
|
|
41
|
+
const icon = success ? theme.ok('✔') : theme.err('✖');
|
|
42
|
+
process.stdout.write(` ${icon} ${this.label}\n`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
render() {
|
|
46
|
+
const f = FRAMES[this.frame % FRAMES.length] ?? FRAMES[0];
|
|
47
|
+
this.frame++;
|
|
48
|
+
process.stdout.write(`\r\x1b[2K ${theme.dim(f)} ${this.label}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
package/dist/ui/theme.js
CHANGED
|
@@ -50,29 +50,16 @@ export const theme = {
|
|
|
50
50
|
},
|
|
51
51
|
};
|
|
52
52
|
export const safeUnicode = process.platform !== 'win32' || Boolean(process.env.WT_SESSION);
|
|
53
|
-
// ───
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
'
|
|
58
|
-
'
|
|
59
|
-
'
|
|
60
|
-
'
|
|
61
|
-
'
|
|
53
|
+
// ─── ASCII logo (figlet "iCopilot") ──────────────────────────────────────────
|
|
54
|
+
// Rendered in cyan/light-blue to match GitHub Copilot's design language.
|
|
55
|
+
const ASCII_LOGO_LINES = [
|
|
56
|
+
' ___ _ _ _ ',
|
|
57
|
+
'|_ _|___ ___ _ __(_) | ___ | |_ ',
|
|
58
|
+
" | |/ __/ _ \\| '_ \\ | |/ _ \\| __|",
|
|
59
|
+
' | | (_| (_) | |_) | | | (_) | |_ ',
|
|
60
|
+
'|___\\___\\___/| .__/|_|_|\\___/ \\__|',
|
|
61
|
+
' |_| ',
|
|
62
62
|
];
|
|
63
|
-
// ─── Pilot mascot (5 rows) ─────────────────────────────────────────────────
|
|
64
|
-
// Purple frame (#A371F7), cyan accents (#39D2D2).
|
|
65
|
-
function buildMascot(c) {
|
|
66
|
-
const fr = (s) => c.hex('#A371F7')(s);
|
|
67
|
-
const cy = (s) => c.hex('#39D2D2')(s);
|
|
68
|
-
return [
|
|
69
|
-
fr(' ╭─────╮ '),
|
|
70
|
-
fr(' │') + cy('◉') + fr(' ') + cy('◉') + fr('│ '),
|
|
71
|
-
fr(' │') + c.hex('#A371F7')(' ─── ') + fr('│ '),
|
|
72
|
-
fr(' ╰──') + cy('┬') + fr('──╯ '),
|
|
73
|
-
cy(' ▶') + c.hex('#A371F7')(' pilot '),
|
|
74
|
-
];
|
|
75
|
-
}
|
|
76
63
|
export function banner(version, model, sessionDir) {
|
|
77
64
|
if (!colorEnabled()) {
|
|
78
65
|
return [
|
|
@@ -84,30 +71,29 @@ export function banner(version, model, sessionDir) {
|
|
|
84
71
|
}
|
|
85
72
|
const { c, name } = palette();
|
|
86
73
|
const green = name === 'light' ? '#166534' : '#3FB950';
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
// Side-by-side: mascot (10 visible chars) + logo
|
|
91
|
-
const combined = logoRows.map((lr, i) => ` ${mascotRows[i] ?? ' '} ${lr}`).join('\n');
|
|
74
|
+
const blue = '#58A6FF';
|
|
75
|
+
// Render ASCII logo in cyan/light-blue
|
|
76
|
+
const logo = ASCII_LOGO_LINES.map((row) => ` ${c.hex(blue).bold(row)}`).join('\n');
|
|
92
77
|
const sessDir = sessionDir ?? '~/.icopilot/sessions/';
|
|
78
|
+
const divider = ` ${c.gray('─'.repeat(50))}`;
|
|
93
79
|
const diag1 = ` ${c.hex(green)('●')} ` +
|
|
94
|
-
`${c.gray('
|
|
95
|
-
`${c.gray('
|
|
80
|
+
`${c.gray('Provider:')} ${c.hex(blue).bold('GitHub Models')} ` +
|
|
81
|
+
`${c.gray('(default: ' + model + ')')}`;
|
|
96
82
|
const diag2 = ` ${c.hex(green)('●')} ` +
|
|
97
|
-
`${c.gray('Session:')} ${c.hex(
|
|
83
|
+
`${c.gray('Session: ')} ${c.hex(blue)('Active')} ` +
|
|
98
84
|
`${c.gray('(' + sessDir + ')')}`;
|
|
99
85
|
const hints = safeUnicode
|
|
100
86
|
? `${c.gray('/help')} for commands ${c.gray('@file')} to add context ${c.gray('Tab')} to autocomplete`
|
|
101
87
|
: `/help for commands @file to add context Tab to autocomplete`;
|
|
102
88
|
return [
|
|
103
89
|
'',
|
|
104
|
-
|
|
105
|
-
'',
|
|
106
|
-
` ${c.gray('v' + version)} ${c.gray('·')} ${c.hex('#58A6FF')(model)}`,
|
|
90
|
+
logo,
|
|
107
91
|
'',
|
|
108
92
|
diag1,
|
|
109
93
|
diag2,
|
|
94
|
+
divider,
|
|
110
95
|
'',
|
|
96
|
+
` ${c.gray('v' + version)} ${c.gray('·')} ${c.hex(blue)(model)}`,
|
|
111
97
|
` ${hints}`,
|
|
112
98
|
'',
|
|
113
99
|
].join('\n');
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, side-effect-free rendering helpers for the full-screen TUI.
|
|
3
|
+
*
|
|
4
|
+
* Every function returns a string (or array of strings) that is painted at an
|
|
5
|
+
* absolute coordinate by the renderer in `modes/tui.ts`. Keeping these helpers
|
|
6
|
+
* pure makes the visual geometry unit-testable without spawning a PTY.
|
|
7
|
+
*
|
|
8
|
+
* The colour language follows the modern engineering-agent CLIs:
|
|
9
|
+
* - deep blue (`\x1b[44m`) track for the tabbed navigation header
|
|
10
|
+
* - magenta (`\x1b[35m`) single line as the conversation/input boundary
|
|
11
|
+
*/
|
|
12
|
+
/** Workspace navigation contexts shown in the Row-0 tab bar. */
|
|
13
|
+
export const WORKSPACE_TABS = ['Session', 'Issues', 'Pull requests', 'Gists', 'Settings'];
|
|
14
|
+
const RESET = '\x1b[0m';
|
|
15
|
+
const BLUE_BG = '\x1b[44m';
|
|
16
|
+
const MAGENTA = '\x1b[35m';
|
|
17
|
+
// eslint-disable-next-line no-control-regex
|
|
18
|
+
const ANSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
19
|
+
/** Remove ANSI escape sequences from a string. */
|
|
20
|
+
export function stripAnsi(text) {
|
|
21
|
+
return text.replace(ANSI_RE, '');
|
|
22
|
+
}
|
|
23
|
+
/** Visible (printable) width of a string, ignoring ANSI escape sequences. */
|
|
24
|
+
export function visibleWidth(text) {
|
|
25
|
+
return stripAnsi(text).length;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Pad (or clip) a string to an exact visible width. Clipping is performed on
|
|
29
|
+
* the plain text so we never emit a half-written escape sequence.
|
|
30
|
+
*/
|
|
31
|
+
export function padVisible(text, cols) {
|
|
32
|
+
const width = visibleWidth(text);
|
|
33
|
+
if (width === cols)
|
|
34
|
+
return text;
|
|
35
|
+
if (width < cols)
|
|
36
|
+
return text + ' '.repeat(cols - width);
|
|
37
|
+
// Too long: fall back to the plain text and hard-clip it.
|
|
38
|
+
return stripAnsi(text).slice(0, cols);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Row 0 — the persistent tabbed navigation header.
|
|
42
|
+
*
|
|
43
|
+
* [Session] Issues Pull requests Gists Settings
|
|
44
|
+
*
|
|
45
|
+
* The active tab is wrapped in brackets and brightened; the whole row sits on a
|
|
46
|
+
* deep-blue background track that spans the full terminal width.
|
|
47
|
+
*/
|
|
48
|
+
export function renderTabBar(activeIndex, cols) {
|
|
49
|
+
const active = ((activeIndex % WORKSPACE_TABS.length) + WORKSPACE_TABS.length) % WORKSPACE_TABS.length;
|
|
50
|
+
const gap = ' ';
|
|
51
|
+
const plainParts = WORKSPACE_TABS.map((tab, i) => (i === active ? `[${tab}]` : ` ${tab} `));
|
|
52
|
+
const plain = plainParts.join(gap);
|
|
53
|
+
if (plain.length > cols) {
|
|
54
|
+
// Degrade gracefully on narrow terminals: blue track + clipped plain text.
|
|
55
|
+
return `${BLUE_BG}${plain.slice(0, cols)}${RESET}`;
|
|
56
|
+
}
|
|
57
|
+
const coloredParts = WORKSPACE_TABS.map((tab, i) => i === active ? `\x1b[1;97m[${tab}]\x1b[22;39m` : `\x1b[2;37m ${tab} \x1b[22;39m`);
|
|
58
|
+
const colored = coloredParts.join(gap);
|
|
59
|
+
const fill = ' '.repeat(Math.max(0, cols - plain.length));
|
|
60
|
+
return `${BLUE_BG}${colored}${fill}${RESET}`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Section B — the left-aligned hero/branding canvas. Returns one string per
|
|
64
|
+
* row so the renderer can place it line-by-line from the top of the timeline.
|
|
65
|
+
*/
|
|
66
|
+
export function renderHero(info, cols) {
|
|
67
|
+
const bullet = '\x1b[35m●\x1b[0m';
|
|
68
|
+
const lines = [];
|
|
69
|
+
lines.push(padVisible(`\x1b[1miCopilot CLI Agent v${info.version}\x1b[0m │ Provider: ${info.provider}`, cols));
|
|
70
|
+
if (info.experimental) {
|
|
71
|
+
lines.push(padVisible(`Experimental Capabilities: ${info.experimental}`, cols));
|
|
72
|
+
}
|
|
73
|
+
lines.push(padVisible('', cols));
|
|
74
|
+
for (const tip of info.tips ?? []) {
|
|
75
|
+
lines.push(padVisible(`${bullet} ${tip}`, cols));
|
|
76
|
+
}
|
|
77
|
+
return lines;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Section C — the horizontal status dock. The left context (cwd + git branch)
|
|
81
|
+
* is left-aligned; the right context (usage metrics + model) is right-aligned.
|
|
82
|
+
* When the two would overlap, the left side is clipped to preserve the metrics.
|
|
83
|
+
*/
|
|
84
|
+
export function renderStatusDock(left, right, cols) {
|
|
85
|
+
const leftWidth = visibleWidth(left);
|
|
86
|
+
const rightWidth = visibleWidth(right);
|
|
87
|
+
// Right side alone fills or overflows the terminal — show only right side.
|
|
88
|
+
if (rightWidth >= cols) {
|
|
89
|
+
return stripAnsi(right).slice(0, cols);
|
|
90
|
+
}
|
|
91
|
+
if (leftWidth + 1 + rightWidth > cols) {
|
|
92
|
+
const room = Math.max(0, cols - rightWidth - 1);
|
|
93
|
+
const clippedLeft = stripAnsi(left).slice(0, room);
|
|
94
|
+
const gap = ' '.repeat(Math.max(0, cols - clippedLeft.length - rightWidth));
|
|
95
|
+
return `${clippedLeft}${gap}${right}`;
|
|
96
|
+
}
|
|
97
|
+
const gap = ' '.repeat(cols - leftWidth - rightWidth);
|
|
98
|
+
return `${left}${gap}${right}`;
|
|
99
|
+
}
|
|
100
|
+
/** The full-width magenta boundary line that separates timeline from input. */
|
|
101
|
+
export function magentaSeparator(cols) {
|
|
102
|
+
return `${MAGENTA}${'─'.repeat(Math.max(0, cols))}${RESET}`;
|
|
103
|
+
}
|
|
104
|
+
/** Absolute bottom-row footer with the persistent keybinding legend. */
|
|
105
|
+
export function renderFooter(cols) {
|
|
106
|
+
const legend = '[Ctrl+C] Quit │ [PageUp/Down] Scroll Output │ [/] System Commands │ [@] Target Context';
|
|
107
|
+
return `\x1b[2m${padVisible(legend, cols)}\x1b[0m`;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Tokenise a slash command by splitting on whitespace thresholds rather than
|
|
111
|
+
* fixed slices: `/model gpt-4o` → { command: 'model', args: ['gpt-4o'] }.
|
|
112
|
+
*/
|
|
113
|
+
export function parseSlashCommand(input) {
|
|
114
|
+
const trimmed = input.trim();
|
|
115
|
+
if (!trimmed.startsWith('/'))
|
|
116
|
+
return null;
|
|
117
|
+
const [command, ...args] = trimmed.slice(1).split(/\s+/);
|
|
118
|
+
if (!command)
|
|
119
|
+
return null;
|
|
120
|
+
return { command, args };
|
|
121
|
+
}
|