agen-vektor 0.3.4 → 0.3.6

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.
@@ -90,8 +90,7 @@ function parseKey(data) {
90
90
  const parts = s.slice(i + 3, e).split(';').map(Number);
91
91
  if (parts.length >= 3) {
92
92
  const [b, x, y] = parts;
93
- const kind = b !== undefined && (b & 64) !== 0 ? 'scroll_up' : b !== undefined && (b & 65) === 65 ? 'scroll_down' : 'click';
94
- events.push({ name: 'mouse', kind, x, y });
93
+ events.push({ name: 'mouse', kind: mouseKind(b ?? 0), x, y });
95
94
  }
96
95
  i = e + 1;
97
96
  continue;
@@ -113,10 +112,9 @@ function parseKey(data) {
113
112
  // X10-style mouse
114
113
  const m = seq.match(/^<(\d+);(\d+);(\d+)([Mm])$/);
115
114
  if (m) {
116
- const b = Number(m[1]);
117
115
  events.push({
118
116
  name: 'mouse',
119
- kind: b === 64 ? 'scroll_up' : b === 65 ? 'scroll_down' : 'click',
117
+ kind: mouseKind(Number(m[1])),
120
118
  x: Number(m[2]),
121
119
  y: Number(m[3]),
122
120
  });
@@ -251,6 +249,18 @@ function parseKey(data) {
251
249
  }
252
250
  return events;
253
251
  }
252
+ /**
253
+ * Map an SGR/X10 mouse button code to a semantic kind.
254
+ * Wheel-up is button 64, wheel-down is 65; modifier bits (4 shift, 8 meta,
255
+ * 16 ctrl) may be OR-ed in, so bit tests must not be exact equality.
256
+ */
257
+ function mouseKind(b) {
258
+ if ((b & 64) === 64 && (b & 1) === 0)
259
+ return 'scroll_up';
260
+ if ((b & 65) === 65)
261
+ return 'scroll_down';
262
+ return 'click';
263
+ }
254
264
  /**
255
265
  * Enter raw mode. Returns a cleanup function.
256
266
  * Works with process.stdin on Linux, macOS, and Termux.
package/dist/tui/app.js CHANGED
@@ -78,6 +78,7 @@ class App {
78
78
  planToExecute = null;
79
79
  abortController = null;
80
80
  spinner = new theme_1.Spinner();
81
+ runStart = null;
81
82
  constructor(agent, opts) {
82
83
  this.agent = agent;
83
84
  this.opts = opts;
@@ -90,7 +91,6 @@ class App {
90
91
  start() {
91
92
  this.connected = true;
92
93
  this.status = 'Ready';
93
- this.addSystem('VectorHead ready. Type a request or press ? for help.');
94
94
  if (this.opts.continueSession && this.sessionName) {
95
95
  const sess = (0, session_1.loadSession)(this.sessionName);
96
96
  if (sess) {
@@ -123,7 +123,8 @@ class App {
123
123
  }
124
124
  switch (ev.name) {
125
125
  case 'char':
126
- if (ev.char === '?')
126
+ // '?' opens help only on an empty input, so questions can be typed
127
+ if (ev.char === '?' && this.input.value.length === 0)
127
128
  this.modal = { type: 'help' };
128
129
  else if (ev.char)
129
130
  this.input.insert(ev.char);
@@ -240,6 +241,7 @@ class App {
240
241
  this.statusColor = (0, theme_1.statusColor)('Thinking');
241
242
  this.spinner.reset();
242
243
  this.abortController = new AbortController();
244
+ this.runStart = Date.now();
243
245
  // Show plan first if requested
244
246
  if (this.opts.showPlan) {
245
247
  this.status = 'Planning…';
@@ -355,6 +357,7 @@ class App {
355
357
  m.content = '(no response)';
356
358
  }
357
359
  this.running = false;
360
+ this.runStart = null;
358
361
  if (this.statusColor !== terminal_1.ANSI.red) {
359
362
  this.status = 'Ready';
360
363
  this.statusColor = terminal_1.ANSI.green;
@@ -710,7 +713,7 @@ class App {
710
713
  return (0, terminal_1.getTerminalSize)().cols;
711
714
  }
712
715
  chatHeight() {
713
- return Math.max(1, (0, terminal_1.getTerminalSize)().rows - 7);
716
+ return Math.max(1, (0, terminal_1.getTerminalSize)().rows - 6);
714
717
  }
715
718
  render() {
716
719
  const { rows, cols } = (0, terminal_1.getTerminalSize)();
@@ -724,50 +727,36 @@ class App {
724
727
  mode: this.agent.config.permissionMode,
725
728
  spinner: this.spinner.frame(),
726
729
  running: this.running,
730
+ elapsed: this.running && this.runStart !== null ? Math.floor((Date.now() - this.runStart) / 1000) : undefined,
727
731
  };
728
- const frame = theme_1.THEME.borderBright;
729
- const innerW = Math.max(4, cols - 2);
730
- // Narrow terminals (mobile portrait) fall back to ASCII borders.
731
- const wide = cols >= 60;
732
- const bl = wide ? terminal_1.BOX.bl : '+';
733
- const br = wide ? terminal_1.BOX.br : '+';
734
- const hz = wide ? terminal_1.BOX.h : '-';
735
- const vr = wide ? terminal_1.BOX.v : '|';
736
- const trtee = wide ? terminal_1.BOX.teeRight : '+';
737
- const tltee = wide ? terminal_1.BOX.teeLeft : '+';
738
732
  const parts = [];
739
- // Row 1-2: framed header + project bar
733
+ // Row 1-2: header + project line
740
734
  parts.push((0, terminal_1.cursorTo)(1, 1) + terminal_1.ANSI.clearLineEnd + (0, statusbar_1.renderHeader)(cols, info));
741
735
  parts.push((0, terminal_1.cursorTo)(2, 1) + terminal_1.ANSI.clearLineEnd + (0, statusbar_1.renderProjectBar)(cols, info));
742
736
  // Row 3: separator
743
- parts.push((0, terminal_1.cursorTo)(3, 1) + terminal_1.ANSI.clearLineEnd + frame + trtee + hz.repeat(innerW) + tltee + theme_1.THEME.reset);
744
- // Chat area (rows 4..) — each line wrapped in the frame
737
+ parts.push((0, terminal_1.cursorTo)(3, 1) + terminal_1.ANSI.clearLineEnd + theme_1.THEME.border + terminal_1.BOX.h.repeat(Math.min(cols, 120)) + theme_1.THEME.reset);
738
+ // Chat area (rows 4..) — Freebuff-style message blocks (welcome screen when empty)
745
739
  const chatH = this.chatHeight();
746
- const chatW = innerW;
747
- const all = (0, chat_1.renderMessages)(this.messages, chatW);
740
+ const all = this.messages.length === 0 ? (0, chat_1.renderWelcome)(cols, chatH, this.agent.cwd) : (0, chat_1.renderMessages)(this.messages, cols);
748
741
  const maxScroll = Math.max(0, all.length - chatH);
749
742
  this.scroll = Math.min(this.scroll, maxScroll);
750
743
  const visible = all.slice(this.scroll, this.scroll + chatH);
751
744
  const chatTop = 4;
752
- const empty = ' '.repeat(innerW);
753
745
  for (let i = 0; i < chatH; i++) {
754
- const content = visible[i] !== undefined ? visible[i] : empty;
755
- parts.push((0, terminal_1.cursorTo)(chatTop + i, 1) + terminal_1.ANSI.clearLineEnd + frame + vr + theme_1.THEME.reset + ' ' + content + ' ' + frame + vr + theme_1.THEME.reset);
746
+ const content = visible[i] !== undefined ? visible[i] : '';
747
+ parts.push((0, terminal_1.cursorTo)(chatTop + i, 1) + terminal_1.ANSI.clearLineEnd + content);
756
748
  }
757
749
  // Separator between chat and input
758
750
  const sepRow = chatTop + chatH;
759
- parts.push((0, terminal_1.cursorTo)(sepRow, 1) + terminal_1.ANSI.clearLineEnd + frame + trtee + hz.repeat(innerW) + tltee + theme_1.THEME.reset);
751
+ parts.push((0, terminal_1.cursorTo)(sepRow, 1) + terminal_1.ANSI.clearLineEnd + theme_1.THEME.border + terminal_1.BOX.h.repeat(Math.min(cols, 120)) + theme_1.THEME.reset);
760
752
  // Input row
761
753
  const inputRow = sepRow + 1;
762
- const rendered = this.input.render(innerW);
763
- parts.push((0, terminal_1.cursorTo)(inputRow, 1) + terminal_1.ANSI.clearLineEnd + frame + vr + theme_1.THEME.reset + ' ' + rendered.line + ' ' + frame + vr + theme_1.THEME.reset);
764
- const cursorCol = rendered.cursorCol + 3;
754
+ const rendered = this.input.render(cols);
755
+ parts.push((0, terminal_1.cursorTo)(inputRow, 1) + terminal_1.ANSI.clearLineEnd + rendered.line);
756
+ const cursorCol = rendered.cursorCol + 1;
765
757
  // Status bar row
766
758
  const statusRow = inputRow + 1;
767
759
  parts.push((0, terminal_1.cursorTo)(statusRow, 1) + terminal_1.ANSI.clearLineEnd + (0, statusbar_1.renderStatusBar)(cols, info));
768
- // Bottom border
769
- const bottomRow = statusRow + 1;
770
- parts.push((0, terminal_1.cursorTo)(bottomRow, 1) + terminal_1.ANSI.clearLineEnd + frame + bl + hz.repeat(innerW) + br + theme_1.THEME.reset);
771
760
  // Modal overlay
772
761
  if (this.modal.type !== 'none') {
773
762
  const overlay = this.renderModal(rows, cols);
package/dist/tui/chat.js CHANGED
@@ -3,64 +3,104 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.renderMessages = renderMessages;
4
4
  exports.defaultScroll = defaultScroll;
5
5
  exports.clampScroll = clampScroll;
6
+ exports.renderWelcome = renderWelcome;
6
7
  /**
7
- * Chat view — renders conversation messages as boxed blocks with scrolling.
8
- * Message types: user, assistant (streamed), tool, system/status, plan.
9
- * Each message renders as a bordered box (Freebuff-style):
10
- * ╭───────────────────────────╮
11
- * VectorHead · model │
12
- * content │
13
- * ╰───────────────────────────╯
8
+ * Chat view — renders conversation messages the Freebuff way (adopted from
9
+ * CodebuffAI/freebuff TUI):
10
+ * - user messages: green left bar + italic text (no label)
11
+ * - assistant messages: plain foreground text
12
+ * - tool calls: compact dim blocks (⚙ tool · preview)
13
+ * No boxes or frames — stays aligned on narrow/mobile terminals.
14
+ *
15
+ * ██╗...VECTORHEAD logo (welcome only)
16
+ * VectorHead will run commands on your behalf to help you build.
17
+ * Directory /home/x
18
+ *
19
+ * │ Inspecting project... (user, italic, green bar)
20
+ *
21
+ * Saya cek dulu file auth.ts. (assistant)
22
+ *
23
+ * ⚙ shell · $ npm test (tool, dim)
14
24
  */
15
25
  const theme_1 = require("./theme");
16
26
  const terminal_1 = require("../utils/terminal");
17
- const KIND_STYLE = {
18
- user: { prefix: 'YOU', color: theme_1.THEME.gold },
19
- assistant: { prefix: 'VectorHead', color: theme_1.THEME.accent },
20
- tool: { prefix: 'TOOL', color: theme_1.THEME.warn },
21
- system: { prefix: '···', color: theme_1.THEME.muted },
22
- error: { prefix: '✗', color: theme_1.THEME.error },
23
- success: { prefix: '✓', color: theme_1.THEME.success },
24
- warning: { prefix: '⚠', color: theme_1.THEME.warn },
25
- };
26
- /** Render one message as a boxed block. Box width = `width` (whole chat area). */
27
- function boxMessage(m, width) {
28
- const style = KIND_STYLE[m.kind];
29
- const prefix = m.prefix || style.prefix;
30
- const dot = m.streaming ? `${theme_1.THEME.warn}◐${theme_1.THEME.reset} ` : `${style.color}●${theme_1.THEME.reset} `;
31
- const title = `${dot}${theme_1.THEME.bold}${style.color}${prefix}${theme_1.THEME.reset}`;
32
- const meta = m.meta ? ` ${theme_1.THEME.dim}${m.meta}${theme_1.THEME.reset}` : '';
33
- const toolName = m.tool ? ` ${theme_1.THEME.warn}${m.tool}${theme_1.THEME.reset}` : '';
34
- const inner = Math.max(8, width - 4);
35
- const border = style.color;
36
- // Narrow terminals (mobile) fall back to ASCII so borders stay aligned.
37
- const ascii = width < 58;
38
- const tl = ascii ? '+' : terminal_1.BOX.tl;
39
- const tr = ascii ? '+' : terminal_1.BOX.tr;
40
- const bl = ascii ? '+' : terminal_1.BOX.bl;
41
- const br = ascii ? '+' : terminal_1.BOX.br;
42
- const hz = ascii ? '-' : terminal_1.BOX.h;
43
- const vr = ascii ? '|' : terminal_1.BOX.v;
44
- const out = [];
45
- // Top border
46
- out.push(border + tl + hz.repeat(inner + 2) + tr + theme_1.THEME.reset);
47
- // Title row: ● prefix · model / tool badge (truncated to fit narrow widths)
48
- out.push(`${border}${vr}${theme_1.THEME.reset} ` + (0, terminal_1.pad)((0, terminal_1.truncate)(title + toolName + meta, inner), inner) + ` ${border}${vr}${theme_1.THEME.reset}`);
49
- // Content rows
27
+ /** Center a string (with ANSI) horizontally within `width`. */
28
+ function center(str, width) {
29
+ const pad = Math.max(0, Math.floor((width - (0, terminal_1.visibleWidth)(str)) / 2));
30
+ return ' '.repeat(pad) + str;
31
+ }
32
+ /** Render one message as Freebuff-style lines. */
33
+ function renderMessage(m, width) {
50
34
  const body = m.content || '(no content)';
51
- for (const line of (0, terminal_1.wrapText)(body, inner)) {
52
- out.push(`${border}${vr}${theme_1.THEME.reset} ` + (0, terminal_1.pad)(line, inner) + ` ${border}${vr}${theme_1.THEME.reset}`);
35
+ const out = [];
36
+ switch (m.kind) {
37
+ case 'user': {
38
+ // Green vertical bar + italic text — Freebuff's signature user style.
39
+ const bar = `${theme_1.THEME.userLine}│${theme_1.THEME.reset}`;
40
+ const inner = Math.max(4, width - 2);
41
+ for (const line of (0, terminal_1.wrapText)(body, inner)) {
42
+ out.push(`${bar} ${terminal_1.ANSI.italic}${line}${theme_1.THEME.reset}`);
43
+ }
44
+ out.push('');
45
+ return out;
46
+ }
47
+ case 'tool': {
48
+ // Compact dim block: ⚙ tool · preview, then dim result lines.
49
+ const label = m.tool ? `⚙ ${m.tool}` : '⚙';
50
+ const parts = body.split('\n');
51
+ const first = `${theme_1.THEME.warn}${label}${theme_1.THEME.reset} ${theme_1.THEME.faint}${(parts[0] || '').trim()}${theme_1.THEME.reset}`;
52
+ if (first.trim()) {
53
+ for (const line of (0, terminal_1.wrapText)(first, width))
54
+ out.push(line);
55
+ }
56
+ const rest = parts.slice(1).filter((l) => l.trim());
57
+ if (rest.length > 0) {
58
+ for (const line of (0, terminal_1.wrapText)(rest.join('\n'), Math.max(4, width - 2))) {
59
+ out.push(`${theme_1.THEME.faint}${line}${theme_1.THEME.reset}`);
60
+ }
61
+ }
62
+ out.push('');
63
+ return out;
64
+ }
65
+ case 'system':
66
+ for (const line of (0, terminal_1.wrapText)(body, width))
67
+ out.push(`${theme_1.THEME.faint}${line}${theme_1.THEME.reset}`);
68
+ out.push('');
69
+ return out;
70
+ case 'error':
71
+ for (const line of (0, terminal_1.wrapText)(body, width))
72
+ out.push(`${theme_1.THEME.error}${line}${theme_1.THEME.reset}`);
73
+ out.push('');
74
+ return out;
75
+ case 'success':
76
+ for (const line of (0, terminal_1.wrapText)(body, width))
77
+ out.push(`${theme_1.THEME.success}${line}${theme_1.THEME.reset}`);
78
+ out.push('');
79
+ return out;
80
+ case 'warning':
81
+ for (const line of (0, terminal_1.wrapText)(body, width))
82
+ out.push(`${theme_1.THEME.warn}${line}${theme_1.THEME.reset}`);
83
+ out.push('');
84
+ return out;
85
+ default: {
86
+ // Assistant: plain foreground text (Freebuff style — no label).
87
+ const marker = m.streaming ? `${theme_1.THEME.info}◐ ${theme_1.THEME.reset}` : '';
88
+ const lines = (0, terminal_1.wrapText)(body, width);
89
+ if (lines.length === 0)
90
+ lines.push('(no content)');
91
+ for (let i = 0; i < lines.length; i++) {
92
+ out.push(i === 0 ? `${marker}${lines[i]}` : lines[i]);
93
+ }
94
+ out.push('');
95
+ return out;
96
+ }
53
97
  }
54
- // Bottom border + blank separator
55
- out.push(border + bl + hz.repeat(inner + 2) + br + theme_1.THEME.reset);
56
- out.push('');
57
- return out;
58
98
  }
59
99
  /** Precompute the full list of rendered lines for all messages. */
60
100
  function renderMessages(messages, width) {
61
101
  const out = [];
62
102
  for (const m of messages) {
63
- out.push(...boxMessage(m, width));
103
+ out.push(...renderMessage(m, width));
64
104
  }
65
105
  return out;
66
106
  }
@@ -73,3 +113,44 @@ function clampScroll(offset, messages, width, height) {
73
113
  const max = Math.max(0, all.length - height);
74
114
  return Math.min(Math.max(0, offset), max);
75
115
  }
116
+ /** Full-width VECTORHEAD ASCII logo (white, Freebuff block style). */
117
+ const VECTORHEAD_LOGO = [
118
+ '██╗ ██╗███████╗ ██████╗████████╗ ██████╗ ██████╗ ██╗ ██╗███████╗ █████╗ ██████╗',
119
+ '██║ ██║██╔════╝██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗██║ ██║██╔════╝██╔══██╗██╔══██╗',
120
+ '██║ ██║█████╗ ██║ ██║ ██║ ██║██████╔╝███████║█████╗ ███████║██║ ██║',
121
+ '╚██╗ ██╔╝██╔══╝ ██║ ██║ ██║ ██║██╔══██╗██╔══██║██╔══╝ ██╔══██║██║ ██║',
122
+ ' ╚████╔╝ ███████╗╚██████╗ ██║ ╚██████╔╝██║ ██║██║ ██║███████╗██║ ██║██████╔╝',
123
+ ' ╚═══╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ',
124
+ ];
125
+ /**
126
+ * Freebuff-style welcome screen (shown while the chat is empty): logo,
127
+ * tagline and Directory line, centered in the chat area. On narrow
128
+ * terminals the full logo is replaced by a compact wordmark.
129
+ */
130
+ function renderWelcome(width, height, cwd) {
131
+ const lines = [];
132
+ if (width >= 85) { // full logo is ~82 cols; narrower terminals get the wordmark
133
+ for (const l of VECTORHEAD_LOGO)
134
+ lines.push(`${theme_1.THEME.textBright}${l}${theme_1.THEME.reset}`);
135
+ }
136
+ else {
137
+ lines.push(`${theme_1.THEME.accent}▮${theme_1.THEME.reset} ${theme_1.THEME.bold}${theme_1.THEME.textBright}VectorHead${theme_1.THEME.reset}`);
138
+ }
139
+ lines.push('');
140
+ for (const t of ['VectorHead will run commands on your behalf', 'to help you build.']) {
141
+ for (const line of (0, terminal_1.wrapText)(t, Math.max(10, width - 4))) {
142
+ lines.push(`${theme_1.THEME.muted}${line}${theme_1.THEME.reset}`);
143
+ }
144
+ }
145
+ lines.push('');
146
+ lines.push(`${theme_1.THEME.muted}Directory ${theme_1.THEME.directory}${(0, terminal_1.truncate)(cwd, Math.max(10, width - 12))}${theme_1.THEME.reset}`);
147
+ const centered = lines.map((l) => center(l, width));
148
+ const padTop = Math.max(0, Math.floor((height - centered.length) / 2));
149
+ const out = [];
150
+ for (let i = 0; i < padTop; i++)
151
+ out.push('');
152
+ out.push(...centered);
153
+ while (out.length < height)
154
+ out.push('');
155
+ return out.slice(0, Math.max(0, height));
156
+ }
@@ -19,24 +19,24 @@ function renderModal(opts, rows, cols) {
19
19
  const left = Math.max(1, Math.floor((cols - innerWidth - 2) / 2));
20
20
  const borderColor = opts.danger ? theme_1.THEME.error : theme_1.THEME.borderBright;
21
21
  const lines = [];
22
- const topBorder = borderColor + terminal_1.BOX.tl + terminal_1.BOX.h.repeat(innerWidth) + terminal_1.BOX.tr;
23
22
  const bottomBorder = borderColor + terminal_1.BOX.bl + terminal_1.BOX.h.repeat(innerWidth) + terminal_1.BOX.br;
24
- // Title placement
25
- const t = (0, terminal_1.pad)(title.slice(0, innerWidth - 2), innerWidth);
23
+ const contentW = innerWidth - 2; // visible area between the side borders
24
+ // Title row: ╭─ Title ────────────────╮ (exactly innerWidth+2 wide)
25
+ const t = (0, terminal_1.truncate)(title, contentW);
26
26
  lines.push((0, terminal_1.cursorTo)(top, left) +
27
- topBorder.slice(0, 2) +
27
+ borderColor + terminal_1.BOX.tl + terminal_1.BOX.h +
28
28
  theme_1.THEME.bold + theme_1.THEME.textBright + t + theme_1.THEME.reset + borderColor +
29
- topBorder.slice(2));
29
+ terminal_1.BOX.h.repeat(Math.max(0, innerWidth - 1 - (0, terminal_1.visibleWidth)(t))) + terminal_1.BOX.tr);
30
30
  const maxBody = height - footerLines.length - 2;
31
31
  for (let i = 0; i < maxBody; i++) {
32
32
  const content = bodyLines[i] ?? '';
33
- const lineStr = (0, terminal_1.pad)(content, innerWidth);
33
+ const lineStr = (0, terminal_1.pad)((0, terminal_1.truncate)(content, contentW), contentW);
34
34
  lines.push((0, terminal_1.cursorTo)(top + 1 + i, left) + borderColor + terminal_1.BOX.v + ' ' + lineStr + theme_1.THEME.reset + borderColor + ' ' + terminal_1.BOX.v);
35
35
  }
36
36
  for (let i = 0; i < footerLines.length; i++) {
37
37
  const row = top + 1 + maxBody + i;
38
38
  const content = footerLines[i] ?? '';
39
- const lineStr = (0, terminal_1.pad)(content, innerWidth);
39
+ const lineStr = (0, terminal_1.pad)((0, terminal_1.truncate)(content, contentW), contentW);
40
40
  lines.push((0, terminal_1.cursorTo)(row, left) + borderColor + terminal_1.BOX.v + ' ' + theme_1.THEME.dim + lineStr + theme_1.THEME.reset + borderColor + ' ' + terminal_1.BOX.v);
41
41
  }
42
42
  lines.push((0, terminal_1.cursorTo)(top + height - 1, left) + bottomBorder);
@@ -51,11 +51,13 @@ function renderSelector(opts) {
51
51
  const top = Math.max(1, Math.floor((opts.rows - height) / 2));
52
52
  const left = Math.max(1, Math.floor((opts.cols - innerWidth - 2) / 2));
53
53
  const lines = [];
54
- const borderColor = terminal_1.ANSI.cyan;
55
- const t = (0, terminal_1.pad)(opts.title.slice(0, innerWidth - 2), innerWidth);
54
+ const borderColor = theme_1.THEME.accent;
55
+ const contentW = innerWidth - 2;
56
+ // Title row: ╭── Title ────────────╮ (exactly innerWidth+2 wide, green accent)
57
+ const t = (0, terminal_1.truncate)(opts.title, contentW);
56
58
  lines.push((0, terminal_1.cursorTo)(top, left) +
57
59
  borderColor + terminal_1.BOX.tl + terminal_1.BOX.h.repeat(2) +
58
- terminal_1.ANSI.bold + terminal_1.ANSI.white + t + terminal_1.ANSI.reset + borderColor +
60
+ terminal_1.ANSI.bold + theme_1.THEME.textBright + t + terminal_1.ANSI.reset + borderColor +
59
61
  terminal_1.BOX.h.repeat(Math.max(0, innerWidth - 2 - (0, terminal_1.visibleWidth)(t))) + terminal_1.BOX.tr);
60
62
  for (let i = 0; i < visible; i++) {
61
63
  const idx = start + i;
@@ -67,19 +69,19 @@ function renderSelector(opts) {
67
69
  const label = marker + option.label;
68
70
  const hint = option.hint ? terminal_1.ANSI.dim + ' — ' + option.hint + terminal_1.ANSI.reset : '';
69
71
  if (idx === opts.selected) {
70
- content = terminal_1.ANSI.bold + terminal_1.ANSI.cyan + (0, terminal_1.pad)(label, innerWidth - (0, terminal_1.visibleWidth)(hint)) + terminal_1.ANSI.reset + hint;
72
+ content = terminal_1.ANSI.bold + theme_1.THEME.accent + (0, terminal_1.truncate)(label, contentW - (0, terminal_1.visibleWidth)(hint)) + terminal_1.ANSI.reset + hint;
71
73
  }
72
74
  else {
73
- content = (0, terminal_1.pad)(label, innerWidth - (0, terminal_1.visibleWidth)(hint)) + hint;
75
+ content = (0, terminal_1.truncate)(label, contentW - (0, terminal_1.visibleWidth)(hint)) + hint;
74
76
  }
75
77
  }
76
78
  else {
77
- content = ' '.repeat(innerWidth);
79
+ content = '';
78
80
  }
79
- lines.push((0, terminal_1.cursorTo)(row, left) + borderColor + terminal_1.BOX.v + ' ' + content + terminal_1.ANSI.reset + ' ' + terminal_1.BOX.v);
81
+ lines.push((0, terminal_1.cursorTo)(row, left) + borderColor + terminal_1.BOX.v + ' ' + (0, terminal_1.pad)(content, contentW) + theme_1.THEME.reset + ' ' + terminal_1.BOX.v);
80
82
  }
81
83
  const footerText = opts.footer ? opts.footer : '↑↓ navigate · Enter select · Esc cancel';
82
- const f = (0, terminal_1.pad)(footerText.slice(0, innerWidth - 2), innerWidth);
84
+ const f = (0, terminal_1.truncate)(footerText, contentW);
83
85
  lines.push((0, terminal_1.cursorTo)(top + visible + 1, left) +
84
86
  borderColor + terminal_1.BOX.bl + terminal_1.BOX.h.repeat(2) + theme_1.THEME.dim + f + theme_1.THEME.reset + borderColor +
85
87
  terminal_1.BOX.h.repeat(Math.max(0, innerWidth - 2 - (0, terminal_1.visibleWidth)(f))) + terminal_1.BOX.br);
package/dist/tui/input.js CHANGED
@@ -5,6 +5,7 @@ exports.InputBox = void 0;
5
5
  * Input box — single-line text input with cursor, history, and hints.
6
6
  */
7
7
  const terminal_1 = require("../utils/terminal");
8
+ const theme_1 = require("./theme");
8
9
  class InputBox {
9
10
  text = '';
10
11
  cursor = 0;
@@ -97,7 +98,7 @@ class InputBox {
97
98
  }
98
99
  /** Render the input line, returns the cursor column (0-based within line). */
99
100
  render(width) {
100
- const prefix = `${terminal_1.ANSI.fg(220)}❯${terminal_1.ANSI.reset} `;
101
+ const prefix = `${theme_1.THEME.accent}❯${terminal_1.ANSI.reset} `;
101
102
  const prefixWidth = 2; // ❯ + space (ANSI codes are invisible)
102
103
  const available = Math.max(1, width - prefixWidth - 1);
103
104
  let display = this.text;
@@ -107,7 +108,9 @@ class InputBox {
107
108
  let start = this.cursor - Math.floor(available / 2);
108
109
  start = Math.max(0, Math.min(start, this.text.length - available));
109
110
  display = this.text.slice(start, start + available);
110
- cursorCol = prefix.length + (this.cursor - start);
111
+ // Visible column: prefix width (2) + offset inside the window.
112
+ // (prefix.length is the raw ANSI byte length — must not be used here.)
113
+ cursorCol = prefixWidth + (this.cursor - start);
111
114
  }
112
115
  let suffix = '';
113
116
  if (!this.text && this.hint) {
@@ -4,27 +4,16 @@ exports.renderHeader = renderHeader;
4
4
  exports.renderProjectBar = renderProjectBar;
5
5
  exports.renderStatusBar = renderStatusBar;
6
6
  /**
7
- * Status bar — top header + project bar + bottom hint bar.
8
- * Renders inside the VectorHead frame (Freebuff-style borders):
9
- * ╭─ VectorHead status ─── provider · model ─╮
10
- * Project: ~/x Mode: ask │
11
- * │ ENTER Send · TAB Commands · ? Help ● ask │
7
+ * Status bar — top header + project line + bottom status bar.
8
+ * Freebuff-style clean lines (no shortcut hints), truncated to fit
9
+ * narrow/mobile terminals. Bottom bar shows live status, spinner and
10
+ * an elapsed timer while the agent is working.
12
11
  */
13
12
  const terminal_1 = require("../utils/terminal");
14
13
  const theme_1 = require("./theme");
15
- const frame = theme_1.THEME.borderBright;
16
- /**
17
- * Frame characters. Narrow terminals (mobile portrait, ~<60 cols) fall back
18
- * to ASCII so borders never misalign on fonts without box-drawing glyphs.
19
- */
20
- function frameChars(width) {
21
- if (width < 60)
22
- return { tl: '+', tr: '+', bl: '+', br: '+', v: '|' };
23
- return { tl: terminal_1.BOX.tl, tr: terminal_1.BOX.tr, bl: terminal_1.BOX.bl, br: terminal_1.BOX.br, v: terminal_1.BOX.v };
24
- }
25
14
  /** Split two ANSI strings across `inner` columns, truncating with … if needed. */
26
15
  function fitTwo(left, right, inner) {
27
- const avail = inner - 3; // spaces around both sides
16
+ const avail = Math.max(4, inner);
28
17
  const l = (0, terminal_1.stripAnsi)(left).length;
29
18
  const r = (0, terminal_1.stripAnsi)(right).length;
30
19
  if (l + r <= avail)
@@ -33,45 +22,46 @@ function fitTwo(left, right, inner) {
33
22
  const maxR = Math.max(0, avail - maxL);
34
23
  return { left: (0, terminal_1.truncate)(left, maxL), right: (0, terminal_1.truncate)(right, maxR) };
35
24
  }
36
- /** Render the top framed header line: ╭─ VectorHead ● status ... provider · model ─╮ */
25
+ /** Render the top header line: VectorHead ● status ... provider · model */
37
26
  function renderHeader(width, info) {
38
27
  const brand = `${theme_1.THEME.bold}${theme_1.THEME.accent}VectorHead${theme_1.THEME.reset}`;
39
28
  const dot = info.connected ? `${theme_1.THEME.success}●${theme_1.THEME.reset}` : `${theme_1.THEME.error}○${theme_1.THEME.reset}`;
40
29
  const status = `${dot} ${(0, theme_1.statusColor)(info.status)}${info.status}${theme_1.THEME.reset}`;
41
30
  const left = `${brand} ${status}`;
42
31
  const provider = `${theme_1.THEME.dim}${info.provider}${theme_1.THEME.reset}`;
43
- const model = `${theme_1.THEME.accentDim}${info.model}${theme_1.THEME.reset}`;
32
+ const model = `${theme_1.THEME.faint}${info.model}${theme_1.THEME.reset}`;
44
33
  const right = `${provider} · ${model}`;
45
- const inner = Math.max(2, width - 2);
46
- const fit = fitTwo(left, right, inner);
47
- const mid = Math.max(1, inner - (0, terminal_1.stripAnsi)(fit.left).length - (0, terminal_1.stripAnsi)(fit.right).length - 3);
48
- const c = frameChars(width);
49
- return `${frame}${c.tl}${theme_1.THEME.reset} ${fit.left}${' '.repeat(mid)}${fit.right} ${frame}${c.tr}${theme_1.THEME.reset}`;
34
+ const fit = fitTwo(left, right, width);
35
+ const mid = Math.max(1, width - (0, terminal_1.stripAnsi)(fit.left).length - (0, terminal_1.stripAnsi)(fit.right).length - 1);
36
+ return theme_1.THEME.bgBar + (0, terminal_1.pad)(`${fit.left}${' '.repeat(mid)}${fit.right}`, width) + theme_1.THEME.reset;
50
37
  }
51
- /** Render the framed project line: Project: <path> ... Mode: ask */
38
+ /** Render the project line: Project: <path> ... Mode: ask */
52
39
  function renderProjectBar(width, info) {
53
- const label = `${theme_1.THEME.dim}Project:${theme_1.THEME.reset} ${theme_1.THEME.text}${info.project}${theme_1.THEME.reset}`;
54
- const modeColor = info.mode === 'yolo' ? theme_1.THEME.warn : theme_1.THEME.muted;
40
+ const label = `${theme_1.THEME.dim}Project:${theme_1.THEME.reset} ${theme_1.THEME.muted}${info.project}${theme_1.THEME.reset}`;
41
+ const modeColor = info.mode === 'yolo' ? theme_1.THEME.warn : theme_1.THEME.faint;
55
42
  const mode = `${theme_1.THEME.dim}Mode:${theme_1.THEME.reset} ${modeColor}${info.mode}${theme_1.THEME.reset}`;
56
- const inner = Math.max(2, width - 2);
57
- const fit = fitTwo(label, mode, inner);
58
- const mid = Math.max(1, inner - (0, terminal_1.stripAnsi)(fit.left).length - (0, terminal_1.stripAnsi)(fit.right).length - 3);
59
- const c = frameChars(width);
60
- return `${frame}${c.v}${theme_1.THEME.reset} ${fit.left}${' '.repeat(mid)}${fit.right} ${frame}${c.v}${theme_1.THEME.reset}`;
43
+ const fit = fitTwo(label, mode, width);
44
+ const mid = Math.max(1, width - (0, terminal_1.stripAnsi)(fit.left).length - (0, terminal_1.stripAnsi)(fit.right).length - 1);
45
+ return theme_1.THEME.bgBar + (0, terminal_1.pad)(`${fit.left}${' '.repeat(mid)}${fit.right}`, width) + theme_1.THEME.reset;
61
46
  }
62
- /** Render the framed bottom hint/status bar with live spinner. */
47
+ /** Render the bottom status bar: live status + spinner + elapsed timer. */
63
48
  function renderStatusBar(width, info) {
64
- const left = `ENTER ${theme_1.THEME.accent}Send${theme_1.THEME.reset} TAB ${theme_1.THEME.accent}Commands${theme_1.THEME.reset} CTRL+C ${theme_1.THEME.accent}Stop${theme_1.THEME.reset} ? ${theme_1.THEME.accent}Help${theme_1.THEME.reset}`;
65
- let right = '';
49
+ const statusColorCode = (0, theme_1.statusColor)(info.status);
50
+ let left;
66
51
  if (info.running && info.spinner) {
67
- right = `${(0, theme_1.statusColor)(info.status)}${info.spinner}${theme_1.THEME.reset} `;
52
+ left = `${statusColorCode}${info.spinner}${theme_1.THEME.reset} ${statusColorCode}${info.status}${theme_1.THEME.reset}`;
53
+ }
54
+ else {
55
+ left = `${statusColorCode}${info.status}${theme_1.THEME.reset}`;
56
+ }
57
+ let right = '';
58
+ if (info.running && info.elapsed !== undefined) {
59
+ right += `${theme_1.THEME.dim}${info.elapsed}s${theme_1.THEME.reset} `;
68
60
  }
69
61
  const conn = info.connected ? `${theme_1.THEME.success}●${theme_1.THEME.reset}` : `${theme_1.THEME.error}○${theme_1.THEME.reset}`;
70
- const mode = info.mode === 'yolo' ? `${theme_1.THEME.warn}YOLO${theme_1.THEME.reset}` : `${theme_1.THEME.muted}ask${theme_1.THEME.reset}`;
62
+ const mode = info.mode === 'yolo' ? `${theme_1.THEME.warn}YOLO${theme_1.THEME.reset}` : `${theme_1.THEME.faint}ask${theme_1.THEME.reset}`;
71
63
  right += `${conn} ${mode}`;
72
- const inner = Math.max(2, width - 2);
73
- const fit = fitTwo(left, right, inner);
74
- const mid = Math.max(1, inner - (0, terminal_1.stripAnsi)(fit.left).length - (0, terminal_1.stripAnsi)(fit.right).length - 3);
75
- const c = frameChars(width);
76
- return `${frame}${c.v}${theme_1.THEME.reset} ${fit.left}${' '.repeat(mid)}${fit.right} ${frame}${c.v}${theme_1.THEME.reset}`;
64
+ const fit = fitTwo(left, right, width);
65
+ const mid = Math.max(1, width - (0, terminal_1.stripAnsi)(fit.left).length - (0, terminal_1.stripAnsi)(fit.right).length - 1);
66
+ return theme_1.THEME.bgBar + (0, terminal_1.pad)(`${fit.left}${' '.repeat(mid)}${fit.right}`, width) + theme_1.THEME.reset;
77
67
  }
package/dist/tui/theme.js CHANGED
@@ -3,25 +3,29 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Spinner = exports.SPINNERS = exports.THEME = void 0;
4
4
  exports.statusColor = statusColor;
5
5
  /**
6
- * VectorHead theme — consistent premium palette for the TUI.
7
- * Warm dark palette with orange accent + gold highlights.
6
+ * VectorHead theme — Freebuff-style palette (adopted from CodebuffAI/freebuff dark theme).
7
+ * Primary brand green #9EFC62, slate neutrals, gray AI line, green user line.
8
8
  */
9
9
  exports.THEME = {
10
- accent: '\x1b[38;5;208m', // bright orange
11
- accentDim: '\x1b[38;5;172m',
12
- gold: '\x1b[38;5;220m',
13
- text: '\x1b[38;5;252m',
10
+ accent: '\x1b[38;5;156m', // Freebuff primary green (#9EFC62 ≈ 156)
11
+ accentDim: '\x1b[38;5;120m',
12
+ gold: '\x1b[38;5;220m', // markdown heading yellow (#facc15 ≈ 220)
13
+ text: '\x1b[38;5;255m', // foreground #f1f5f9 ≈ 255
14
14
  textBright: '\x1b[38;5;255m',
15
- muted: '\x1b[38;5;244m',
16
- faint: '\x1b[38;5;240m',
17
- border: '\x1b[38;5;94m',
18
- borderBright: '\x1b[38;5;208m',
15
+ muted: '\x1b[38;5;250m', // #acb3bf ≈ 250
16
+ faint: '\x1b[38;5;244m',
17
+ directory: '\x1b[38;5;249m', // #9CA3AF ≈ 249
18
+ border: '\x1b[38;5;60m', // slate #536175 ≈ 60
19
+ borderBright: '\x1b[38;5;156m',
19
20
  success: '\x1b[38;5;114m',
20
- warn: '\x1b[38;5;215m',
21
+ warn: '\x1b[38;5;215m', // #FFA500 ≈ 215
21
22
  error: '\x1b[38;5;203m',
22
- info: '\x1b[38;5;111m',
23
- bgBar: '\x1b[48;5;235m',
24
- bgPanel: '\x1b[48;5;236m',
23
+ info: '\x1b[38;5;156m',
24
+ // Message indicator lines (Freebuff): gray for AI, green for user
25
+ aiLine: '\x1b[38;5;244m',
26
+ userLine: '\x1b[38;5;156m',
27
+ bgBar: '\x1b[48;5;236m',
28
+ bgPanel: '\x1b[48;5;237m',
25
29
  bold: '\x1b[1m',
26
30
  dim: '\x1b[2m',
27
31
  reset: '\x1b[0m',
@@ -39,8 +43,8 @@ function statusColor(status) {
39
43
  return exports.THEME.success;
40
44
  if (s.includes('stopped') || s.includes('cancel'))
41
45
  return exports.THEME.muted;
42
- if (s.includes('thinking') || s.includes('plan'))
43
- return exports.THEME.info;
46
+ if (s.includes('thinking') || s.includes('plan') || s.includes('work'))
47
+ return exports.THEME.accent;
44
48
  if (s.includes('run') || s.includes('exec'))
45
49
  return exports.THEME.accent;
46
50
  return exports.THEME.info;
@@ -91,7 +91,8 @@ function getTerminalSize() {
91
91
  }
92
92
  /**
93
93
  * Wrap text to a given width, respecting ANSI escape sequences so they
94
- * are not counted toward the visible length.
94
+ * are not counted toward the visible length. Surrogate pairs (emoji) are
95
+ * counted as a single 2-column character.
95
96
  */
96
97
  function wrapText(text, width) {
97
98
  if (width <= 0)
@@ -129,14 +130,34 @@ function wrapText(text, width) {
129
130
  i++;
130
131
  continue;
131
132
  }
132
- // Measure visible width (approximate wide chars as 2)
133
- const w = ch.charCodeAt(0) > 0x2fff ? 2 : 1;
133
+ // One full code point surrogate pairs count as a single 2-column char.
134
+ const cp = text.codePointAt(i);
135
+ const unit = String.fromCodePoint(cp);
136
+ const w = cp > 0x2fff ? 2 : 1;
134
137
  if (visibleLen + w > width && visibleLen > 0) {
135
- flushLine();
138
+ // Word-aware wrap (Freebuff uses wrapMode: 'word'): break at the last
139
+ // space so words are not split mid-word. ANSI codes never contain a
140
+ // space, so lastIndexOf(' ') on the raw buffer is safe.
141
+ const lastSpace = current.lastIndexOf(' ');
142
+ if (lastSpace > 0) {
143
+ const carried = current.slice(lastSpace + 1);
144
+ const carriedW = visibleWidth(carried);
145
+ if (carriedW > 0 && carriedW + w <= width) {
146
+ lines.push(current.slice(0, lastSpace));
147
+ current = carried;
148
+ visibleLen = carriedW;
149
+ }
150
+ else {
151
+ flushLine();
152
+ }
153
+ }
154
+ else {
155
+ flushLine();
156
+ }
136
157
  }
137
- current += ch;
158
+ current += unit;
138
159
  visibleLen += w;
139
- i++;
160
+ i += unit.length;
140
161
  }
141
162
  flushLine();
142
163
  return lines;
@@ -145,11 +166,11 @@ function wrapText(text, width) {
145
166
  function stripAnsi(text) {
146
167
  return text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
147
168
  }
148
- /** Visible width of a string without ANSI codes. */
169
+ /** Visible width of a string without ANSI codes (emoji counted as 2). */
149
170
  function visibleWidth(text) {
150
171
  let w = 0;
151
172
  for (const ch of stripAnsi(text)) {
152
- w += ch.charCodeAt(0) > 0x2fff ? 2 : 1;
173
+ w += ch.codePointAt(0) > 0x2fff ? 2 : 1;
153
174
  }
154
175
  return w;
155
176
  }
@@ -160,12 +181,40 @@ function pad(str, width) {
160
181
  return str;
161
182
  return str + ' '.repeat(width - len);
162
183
  }
163
- /** Truncate a string (with ANSI) to a max visible width. */
184
+ /**
185
+ * Truncate a string (with ANSI) to a max visible width. ANSI sequences are
186
+ * copied verbatim so colors survive; when truncated, one column is reserved
187
+ * for '…' and a closing reset is appended so styling never bleeds out.
188
+ */
164
189
  function truncate(str, width) {
165
- const clean = stripAnsi(str);
166
- if (clean.length <= width)
190
+ if (width <= 0)
191
+ return '';
192
+ if (visibleWidth(str) <= width)
167
193
  return str;
168
- return clean.slice(0, Math.max(0, width - 1)) + '…';
194
+ const ESC_RE = /^\x1b\[[0-9;]*[A-Za-z]/;
195
+ let out = '';
196
+ let vis = 0;
197
+ let i = 0;
198
+ while (i < str.length && vis < width - 1) {
199
+ const ch = str[i];
200
+ if (ch === '\x1b') {
201
+ const m = str.slice(i).match(ESC_RE);
202
+ if (m) {
203
+ out += m[0];
204
+ i += m[0].length;
205
+ continue;
206
+ }
207
+ }
208
+ const cp = str.codePointAt(i);
209
+ const unit = String.fromCodePoint(cp);
210
+ const w = cp > 0x2fff ? 2 : 1;
211
+ if (vis + w > width - 1)
212
+ break; // wide char leaves no room for '…'
213
+ out += unit;
214
+ vis += w;
215
+ i += unit.length;
216
+ }
217
+ return out + '…' + exports.ANSI.reset;
169
218
  }
170
219
  /** Render a line with a trailing newline reset applied. */
171
220
  function line(text = '') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agen-vektor",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "VectorHead (agen-vektor) — AI Coding Agent CLI/TUI for Linux & Termux. Multi-provider, tool calling, session, permission system.",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "LICENSE"
14
14
  ],
15
15
  "scripts": {
16
- "build": "tsc",
16
+ "build": "tsc && chmod +x dist/cli/index.js",
17
17
  "dev": "tsx src/cli/index.ts",
18
18
  "start": "node dist/cli/index.js",
19
19
  "test": "tsx --test tests/*.test.ts",