icopilot 2.3.4 → 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 +7 -0
- package/dist/modes/tui.js +115 -26
- package/dist/ui/tui-layout.js +121 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@ 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
|
+
|
|
8
15
|
## [2.3.4] — 2026-06-29
|
|
9
16
|
|
|
10
17
|
### Fixed
|
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
|
}
|
|
@@ -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
|
+
}
|