agen-vektor 0.3.4 → 0.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.
@@ -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
@@ -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);
@@ -710,7 +711,7 @@ class App {
710
711
  return (0, terminal_1.getTerminalSize)().cols;
711
712
  }
712
713
  chatHeight() {
713
- return Math.max(1, (0, terminal_1.getTerminalSize)().rows - 7);
714
+ return Math.max(1, (0, terminal_1.getTerminalSize)().rows - 6);
714
715
  }
715
716
  render() {
716
717
  const { rows, cols } = (0, terminal_1.getTerminalSize)();
@@ -725,49 +726,34 @@ class App {
725
726
  spinner: this.spinner.frame(),
726
727
  running: this.running,
727
728
  };
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
729
  const parts = [];
739
- // Row 1-2: framed header + project bar
730
+ // Row 1-2: header + project line
740
731
  parts.push((0, terminal_1.cursorTo)(1, 1) + terminal_1.ANSI.clearLineEnd + (0, statusbar_1.renderHeader)(cols, info));
741
732
  parts.push((0, terminal_1.cursorTo)(2, 1) + terminal_1.ANSI.clearLineEnd + (0, statusbar_1.renderProjectBar)(cols, info));
742
733
  // 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
734
+ 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);
735
+ // Chat area (rows 4..) — clean Freebuff-style message blocks
745
736
  const chatH = this.chatHeight();
746
- const chatW = innerW;
747
- const all = (0, chat_1.renderMessages)(this.messages, chatW);
737
+ const all = (0, chat_1.renderMessages)(this.messages, cols);
748
738
  const maxScroll = Math.max(0, all.length - chatH);
749
739
  this.scroll = Math.min(this.scroll, maxScroll);
750
740
  const visible = all.slice(this.scroll, this.scroll + chatH);
751
741
  const chatTop = 4;
752
- const empty = ' '.repeat(innerW);
753
742
  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);
743
+ const content = visible[i] !== undefined ? visible[i] : '';
744
+ parts.push((0, terminal_1.cursorTo)(chatTop + i, 1) + terminal_1.ANSI.clearLineEnd + content);
756
745
  }
757
746
  // Separator between chat and input
758
747
  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);
748
+ 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
749
  // Input row
761
750
  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;
751
+ const rendered = this.input.render(cols);
752
+ parts.push((0, terminal_1.cursorTo)(inputRow, 1) + terminal_1.ANSI.clearLineEnd + rendered.line);
753
+ const cursorCol = rendered.cursorCol + 1;
765
754
  // Status bar row
766
755
  const statusRow = inputRow + 1;
767
756
  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
757
  // Modal overlay
772
758
  if (this.modal.type !== 'none') {
773
759
  const overlay = this.renderModal(rows, cols);
package/dist/tui/chat.js CHANGED
@@ -4,16 +4,25 @@ exports.renderMessages = renderMessages;
4
4
  exports.defaultScroll = defaultScroll;
5
5
  exports.clampScroll = clampScroll;
6
6
  /**
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
- * ╰───────────────────────────╯
7
+ * Chat view — renders conversation messages the Freebuff way:
8
+ * clean plain-text blocks with a small colored label row. User messages
9
+ * are right-aligned (chat-style bubble), everything else left-aligned.
10
+ * No boxes or frames — stays aligned on narrow/mobile terminals.
11
+ *
12
+ * VectorHead · model
13
+ * Inspecting project...
14
+ *
15
+ * ● YOU
16
+ * perbaiki error auth
17
+ *
18
+ * ⚙ shell · $ npm test
14
19
  */
15
20
  const theme_1 = require("./theme");
16
21
  const terminal_1 = require("../utils/terminal");
22
+ /** Right-align a string to a fixed visible width (leading padding). */
23
+ function padLeft(str, width) {
24
+ return ' '.repeat(Math.max(0, width - (0, terminal_1.visibleWidth)(str))) + str;
25
+ }
17
26
  const KIND_STYLE = {
18
27
  user: { prefix: 'YOU', color: theme_1.THEME.gold },
19
28
  assistant: { prefix: 'VectorHead', color: theme_1.THEME.accent },
@@ -23,44 +32,31 @@ const KIND_STYLE = {
23
32
  success: { prefix: '✓', color: theme_1.THEME.success },
24
33
  warning: { prefix: '⚠', color: theme_1.THEME.warn },
25
34
  };
26
- /** Render one message as a boxed block. Box width = `width` (whole chat area). */
27
- function boxMessage(m, width) {
35
+ /** Render one message as a label row + wrapped content (user right-aligned). */
36
+ function renderMessage(m, width) {
28
37
  const style = KIND_STYLE[m.kind];
29
38
  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}`;
39
+ const dot = m.streaming ? `${theme_1.THEME.warn}◐${theme_1.THEME.reset}` : `${style.color}●${theme_1.THEME.reset}`;
32
40
  const meta = m.meta ? ` ${theme_1.THEME.dim}${m.meta}${theme_1.THEME.reset}` : '';
33
41
  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
42
+ const rightAlign = m.kind === 'user';
43
+ // Label row: ● Label · tool · model (right-aligned for user, truncated to fit narrow widths)
44
+ const label = `${dot} ${theme_1.THEME.bold}${style.color}${prefix}${theme_1.THEME.reset}${toolName}${meta}`;
45
+ const out = [rightAlign ? padLeft((0, terminal_1.truncate)(label, width), width) : (0, terminal_1.truncate)(label, width)];
46
+ // Content rows wrapped to fit; user wraps the full width for a flush right edge
50
47
  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}`);
48
+ const wrapW = rightAlign ? width : Math.max(4, width - 2);
49
+ for (const line of (0, terminal_1.wrapText)(body, wrapW)) {
50
+ out.push(rightAlign ? padLeft(line, width) : ` ${line}`);
53
51
  }
54
- // Bottom border + blank separator
55
- out.push(border + bl + hz.repeat(inner + 2) + br + theme_1.THEME.reset);
56
- out.push('');
52
+ out.push(''); // blank separator between messages
57
53
  return out;
58
54
  }
59
55
  /** Precompute the full list of rendered lines for all messages. */
60
56
  function renderMessages(messages, width) {
61
57
  const out = [];
62
58
  for (const m of messages) {
63
- out.push(...boxMessage(m, width));
59
+ out.push(...renderMessage(m, width));
64
60
  }
65
61
  return out;
66
62
  }
@@ -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);
@@ -52,7 +52,9 @@ function renderSelector(opts) {
52
52
  const left = Math.max(1, Math.floor((opts.cols - innerWidth - 2) / 2));
53
53
  const lines = [];
54
54
  const borderColor = terminal_1.ANSI.cyan;
55
- const t = (0, terminal_1.pad)(opts.title.slice(0, innerWidth - 2), innerWidth);
55
+ const contentW = innerWidth - 2;
56
+ // Title row: ╭── Title ────────────╮ (exactly innerWidth+2 wide)
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
60
  terminal_1.ANSI.bold + terminal_1.ANSI.white + t + terminal_1.ANSI.reset + borderColor +
@@ -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 + terminal_1.ANSI.cyan + (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
@@ -107,7 +107,9 @@ class InputBox {
107
107
  let start = this.cursor - Math.floor(available / 2);
108
108
  start = Math.max(0, Math.min(start, this.text.length - available));
109
109
  display = this.text.slice(start, start + available);
110
- cursorCol = prefix.length + (this.cursor - start);
110
+ // Visible column: prefix width (2) + offset inside the window.
111
+ // (prefix.length is the raw ANSI byte length — must not be used here.)
112
+ cursorCol = prefixWidth + (this.cursor - start);
111
113
  }
112
114
  let suffix = '';
113
115
  if (!this.text && this.hint) {
@@ -4,27 +4,15 @@ 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 hint bar.
8
+ * Clean Freebuff-style lines (no frames), truncated to fit narrow/mobile
9
+ * terminals.
12
10
  */
13
11
  const terminal_1 = require("../utils/terminal");
14
12
  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
13
  /** Split two ANSI strings across `inner` columns, truncating with … if needed. */
26
14
  function fitTwo(left, right, inner) {
27
- const avail = inner - 3; // spaces around both sides
15
+ const avail = Math.max(4, inner);
28
16
  const l = (0, terminal_1.stripAnsi)(left).length;
29
17
  const r = (0, terminal_1.stripAnsi)(right).length;
30
18
  if (l + r <= avail)
@@ -33,7 +21,7 @@ function fitTwo(left, right, inner) {
33
21
  const maxR = Math.max(0, avail - maxL);
34
22
  return { left: (0, terminal_1.truncate)(left, maxL), right: (0, terminal_1.truncate)(right, maxR) };
35
23
  }
36
- /** Render the top framed header line: ╭─ VectorHead ● status ... provider · model ─╮ */
24
+ /** Render the top header line: VectorHead ● status ... provider · model */
37
25
  function renderHeader(width, info) {
38
26
  const brand = `${theme_1.THEME.bold}${theme_1.THEME.accent}VectorHead${theme_1.THEME.reset}`;
39
27
  const dot = info.connected ? `${theme_1.THEME.success}●${theme_1.THEME.reset}` : `${theme_1.THEME.error}○${theme_1.THEME.reset}`;
@@ -42,24 +30,20 @@ function renderHeader(width, info) {
42
30
  const provider = `${theme_1.THEME.dim}${info.provider}${theme_1.THEME.reset}`;
43
31
  const model = `${theme_1.THEME.accentDim}${info.model}${theme_1.THEME.reset}`;
44
32
  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}`;
33
+ const fit = fitTwo(left, right, width);
34
+ const mid = Math.max(1, width - (0, terminal_1.stripAnsi)(fit.left).length - (0, terminal_1.stripAnsi)(fit.right).length - 1);
35
+ return theme_1.THEME.bgBar + (0, terminal_1.pad)(`${fit.left}${' '.repeat(mid)}${fit.right}`, width) + theme_1.THEME.reset;
50
36
  }
51
- /** Render the framed project line: Project: <path> ... Mode: ask */
37
+ /** Render the project line: Project: <path> ... Mode: ask */
52
38
  function renderProjectBar(width, info) {
53
39
  const label = `${theme_1.THEME.dim}Project:${theme_1.THEME.reset} ${theme_1.THEME.text}${info.project}${theme_1.THEME.reset}`;
54
40
  const modeColor = info.mode === 'yolo' ? theme_1.THEME.warn : theme_1.THEME.muted;
55
41
  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}`;
42
+ const fit = fitTwo(label, mode, width);
43
+ const mid = Math.max(1, width - (0, terminal_1.stripAnsi)(fit.left).length - (0, terminal_1.stripAnsi)(fit.right).length - 1);
44
+ return theme_1.THEME.bgBar + (0, terminal_1.pad)(`${fit.left}${' '.repeat(mid)}${fit.right}`, width) + theme_1.THEME.reset;
61
45
  }
62
- /** Render the framed bottom hint/status bar with live spinner. */
46
+ /** Render the bottom hint/status bar with live spinner. */
63
47
  function renderStatusBar(width, info) {
64
48
  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
49
  let right = '';
@@ -69,9 +53,7 @@ function renderStatusBar(width, info) {
69
53
  const conn = info.connected ? `${theme_1.THEME.success}●${theme_1.THEME.reset}` : `${theme_1.THEME.error}○${theme_1.THEME.reset}`;
70
54
  const mode = info.mode === 'yolo' ? `${theme_1.THEME.warn}YOLO${theme_1.THEME.reset}` : `${theme_1.THEME.muted}ask${theme_1.THEME.reset}`;
71
55
  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}`;
56
+ const fit = fitTwo(left, right, width);
57
+ const mid = Math.max(1, width - (0, terminal_1.stripAnsi)(fit.left).length - (0, terminal_1.stripAnsi)(fit.right).length - 1);
58
+ return theme_1.THEME.bgBar + (0, terminal_1.pad)(`${fit.left}${' '.repeat(mid)}${fit.right}`, width) + theme_1.THEME.reset;
77
59
  }
@@ -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,16 @@ 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
138
  flushLine();
136
139
  }
137
- current += ch;
140
+ current += unit;
138
141
  visibleLen += w;
139
- i++;
142
+ i += unit.length;
140
143
  }
141
144
  flushLine();
142
145
  return lines;
@@ -145,11 +148,11 @@ function wrapText(text, width) {
145
148
  function stripAnsi(text) {
146
149
  return text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
147
150
  }
148
- /** Visible width of a string without ANSI codes. */
151
+ /** Visible width of a string without ANSI codes (emoji counted as 2). */
149
152
  function visibleWidth(text) {
150
153
  let w = 0;
151
154
  for (const ch of stripAnsi(text)) {
152
- w += ch.charCodeAt(0) > 0x2fff ? 2 : 1;
155
+ w += ch.codePointAt(0) > 0x2fff ? 2 : 1;
153
156
  }
154
157
  return w;
155
158
  }
@@ -160,12 +163,40 @@ function pad(str, width) {
160
163
  return str;
161
164
  return str + ' '.repeat(width - len);
162
165
  }
163
- /** Truncate a string (with ANSI) to a max visible width. */
166
+ /**
167
+ * Truncate a string (with ANSI) to a max visible width. ANSI sequences are
168
+ * copied verbatim so colors survive; when truncated, one column is reserved
169
+ * for '…' and a closing reset is appended so styling never bleeds out.
170
+ */
164
171
  function truncate(str, width) {
165
- const clean = stripAnsi(str);
166
- if (clean.length <= width)
172
+ if (width <= 0)
173
+ return '';
174
+ if (visibleWidth(str) <= width)
167
175
  return str;
168
- return clean.slice(0, Math.max(0, width - 1)) + '…';
176
+ const ESC_RE = /^\x1b\[[0-9;]*[A-Za-z]/;
177
+ let out = '';
178
+ let vis = 0;
179
+ let i = 0;
180
+ while (i < str.length && vis < width - 1) {
181
+ const ch = str[i];
182
+ if (ch === '\x1b') {
183
+ const m = str.slice(i).match(ESC_RE);
184
+ if (m) {
185
+ out += m[0];
186
+ i += m[0].length;
187
+ continue;
188
+ }
189
+ }
190
+ const cp = str.codePointAt(i);
191
+ const unit = String.fromCodePoint(cp);
192
+ const w = cp > 0x2fff ? 2 : 1;
193
+ if (vis + w > width - 1)
194
+ break; // wide char leaves no room for '…'
195
+ out += unit;
196
+ vis += w;
197
+ i += unit.length;
198
+ }
199
+ return out + '…' + exports.ANSI.reset;
169
200
  }
170
201
  /** Render a line with a trailing newline reset applied. */
171
202
  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.5",
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",