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 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', markDirty);
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
- const chatBottom = Math.max(chatTop, rows - 3);
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
- writeRaw(statusLine(session, cols, busy));
89
- const lines = wrapLines(chat.trimEnd(), Math.max(1, cols));
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
- writeRaw(pad(visible[i] || '', cols));
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
- // separator with thinking indicator
96
- writeRaw(`\x1b[${Math.max(1, rows - 2)};1H`);
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(''.repeat(sideLen) +
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('─'.repeat(cols));
166
+ writeRaw(magentaSeparator(cols));
106
167
  }
107
- writeRaw(`\x1b[${Math.max(1, rows - 1)};1H`);
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 prompt = `${promptIcon} ${rl.line || ''}`;
110
- writeRaw(pad(prompt, cols));
111
- writeRaw(`\x1b[${rows};1H`);
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));
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
- process.stdin.on('keypress', markDirty);
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
- chat = 'Welcome to iCopilot TUI prototype. Type /help for commands.\n';
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 statusLine(session, cols, busy) {
258
- const mode = session.state.mode.toUpperCase();
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
- 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 `;
262
- return `\x1b[7m${pad(text, cols)}\x1b[0m`;
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 pad(text, cols) {
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icopilot",
3
- "version": "2.3.4",
3
+ "version": "2.3.5",
4
4
  "description": "iCopilot — terminal-native agentic CLI powered by GitHub Models",
5
5
  "type": "module",
6
6
  "bin": {