agen-vektor 0.3.3 → 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);
@@ -184,6 +185,10 @@ class App {
184
185
  this.scroll = 0;
185
186
  this.addSystem('Conversation cleared.');
186
187
  break;
188
+ case 'ctrl_d':
189
+ // EOF / Ctrl+D — another way to quit (handy on mobile where Ctrl is awkward)
190
+ this.exitRequested = true;
191
+ break;
187
192
  case 'tab':
188
193
  this.modal = { type: 'commands' };
189
194
  break;
@@ -706,7 +711,7 @@ class App {
706
711
  return (0, terminal_1.getTerminalSize)().cols;
707
712
  }
708
713
  chatHeight() {
709
- return Math.max(1, (0, terminal_1.getTerminalSize)().rows - 7);
714
+ return Math.max(1, (0, terminal_1.getTerminalSize)().rows - 6);
710
715
  }
711
716
  render() {
712
717
  const { rows, cols } = (0, terminal_1.getTerminalSize)();
@@ -721,41 +726,34 @@ class App {
721
726
  spinner: this.spinner.frame(),
722
727
  running: this.running,
723
728
  };
724
- const frame = theme_1.THEME.borderBright;
725
- const innerW = Math.max(4, cols - 2);
726
729
  const parts = [];
727
- // Row 1-2: framed header + project bar
730
+ // Row 1-2: header + project line
728
731
  parts.push((0, terminal_1.cursorTo)(1, 1) + terminal_1.ANSI.clearLineEnd + (0, statusbar_1.renderHeader)(cols, info));
729
732
  parts.push((0, terminal_1.cursorTo)(2, 1) + terminal_1.ANSI.clearLineEnd + (0, statusbar_1.renderProjectBar)(cols, info));
730
733
  // Row 3: separator
731
- parts.push((0, terminal_1.cursorTo)(3, 1) + terminal_1.ANSI.clearLineEnd + frame + terminal_1.BOX.teeRight + terminal_1.BOX.h.repeat(innerW) + terminal_1.BOX.teeLeft + theme_1.THEME.reset);
732
- // 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
733
736
  const chatH = this.chatHeight();
734
- const chatW = innerW;
735
- const all = (0, chat_1.renderMessages)(this.messages, chatW);
737
+ const all = (0, chat_1.renderMessages)(this.messages, cols);
736
738
  const maxScroll = Math.max(0, all.length - chatH);
737
739
  this.scroll = Math.min(this.scroll, maxScroll);
738
740
  const visible = all.slice(this.scroll, this.scroll + chatH);
739
741
  const chatTop = 4;
740
- const empty = ' '.repeat(innerW);
741
742
  for (let i = 0; i < chatH; i++) {
742
- const content = visible[i] !== undefined ? visible[i] : empty;
743
- parts.push((0, terminal_1.cursorTo)(chatTop + i, 1) + terminal_1.ANSI.clearLineEnd + frame + terminal_1.BOX.v + theme_1.THEME.reset + ' ' + content + ' ' + frame + terminal_1.BOX.v + 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);
744
745
  }
745
746
  // Separator between chat and input
746
747
  const sepRow = chatTop + chatH;
747
- parts.push((0, terminal_1.cursorTo)(sepRow, 1) + terminal_1.ANSI.clearLineEnd + frame + terminal_1.BOX.teeRight + terminal_1.BOX.h.repeat(innerW) + terminal_1.BOX.teeLeft + 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);
748
749
  // Input row
749
750
  const inputRow = sepRow + 1;
750
- const rendered = this.input.render(innerW);
751
- parts.push((0, terminal_1.cursorTo)(inputRow, 1) + terminal_1.ANSI.clearLineEnd + frame + terminal_1.BOX.v + theme_1.THEME.reset + ' ' + rendered.line + ' ' + frame + terminal_1.BOX.v + theme_1.THEME.reset);
752
- 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;
753
754
  // Status bar row
754
755
  const statusRow = inputRow + 1;
755
756
  parts.push((0, terminal_1.cursorTo)(statusRow, 1) + terminal_1.ANSI.clearLineEnd + (0, statusbar_1.renderStatusBar)(cols, info));
756
- // Bottom border
757
- const bottomRow = statusRow + 1;
758
- parts.push((0, terminal_1.cursorTo)(bottomRow, 1) + terminal_1.ANSI.clearLineEnd + frame + terminal_1.BOX.bl + terminal_1.BOX.h.repeat(innerW) + terminal_1.BOX.br + theme_1.THEME.reset);
759
757
  // Modal overlay
760
758
  if (this.modal.type !== 'none') {
761
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,36 +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
- const out = [];
37
- // Top border
38
- out.push(border + terminal_1.BOX.tl + terminal_1.BOX.h.repeat(inner + 2) + terminal_1.BOX.tr + theme_1.THEME.reset);
39
- // Title row: ● prefix · model / tool badge
40
- out.push(`${border}${terminal_1.BOX.v}${theme_1.THEME.reset} ` + (0, terminal_1.pad)(title + toolName + meta, inner) + ` ${border}${terminal_1.BOX.v}${theme_1.THEME.reset}`);
41
- // 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
42
47
  const body = m.content || '(no content)';
43
- for (const line of (0, terminal_1.wrapText)(body, inner)) {
44
- out.push(`${border}${terminal_1.BOX.v}${theme_1.THEME.reset} ` + (0, terminal_1.pad)(line, inner) + ` ${border}${terminal_1.BOX.v}${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}`);
45
51
  }
46
- // Bottom border + blank separator
47
- out.push(border + terminal_1.BOX.bl + terminal_1.BOX.h.repeat(inner + 2) + terminal_1.BOX.br + theme_1.THEME.reset);
48
- out.push('');
52
+ out.push(''); // blank separator between messages
49
53
  return out;
50
54
  }
51
55
  /** Precompute the full list of rendered lines for all messages. */
52
56
  function renderMessages(messages, width) {
53
57
  const out = [];
54
58
  for (const m of messages) {
55
- out.push(...boxMessage(m, width));
59
+ out.push(...renderMessage(m, width));
56
60
  }
57
61
  return out;
58
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,16 +4,24 @@ 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
- /** Render the top framed header line: ╭─ VectorHead ● status ... provider · model ─╮ */
13
+ /** Split two ANSI strings across `inner` columns, truncating with … if needed. */
14
+ function fitTwo(left, right, inner) {
15
+ const avail = Math.max(4, inner);
16
+ const l = (0, terminal_1.stripAnsi)(left).length;
17
+ const r = (0, terminal_1.stripAnsi)(right).length;
18
+ if (l + r <= avail)
19
+ return { left, right };
20
+ const maxL = Math.max(0, Math.floor((avail * l) / (l + r)));
21
+ const maxR = Math.max(0, avail - maxL);
22
+ return { left: (0, terminal_1.truncate)(left, maxL), right: (0, terminal_1.truncate)(right, maxR) };
23
+ }
24
+ /** Render the top header line: VectorHead ● status ... provider · model */
17
25
  function renderHeader(width, info) {
18
26
  const brand = `${theme_1.THEME.bold}${theme_1.THEME.accent}VectorHead${theme_1.THEME.reset}`;
19
27
  const dot = info.connected ? `${theme_1.THEME.success}●${theme_1.THEME.reset}` : `${theme_1.THEME.error}○${theme_1.THEME.reset}`;
@@ -22,20 +30,20 @@ function renderHeader(width, info) {
22
30
  const provider = `${theme_1.THEME.dim}${info.provider}${theme_1.THEME.reset}`;
23
31
  const model = `${theme_1.THEME.accentDim}${info.model}${theme_1.THEME.reset}`;
24
32
  const right = `${provider} · ${model}`;
25
- const inner = Math.max(2, width - 2);
26
- const mid = Math.max(1, inner - (0, terminal_1.stripAnsi)(left).length - (0, terminal_1.stripAnsi)(right).length - 3);
27
- return `${frame}${terminal_1.BOX.tl}${theme_1.THEME.reset} ${left}${' '.repeat(mid)}${right} ${frame}${terminal_1.BOX.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;
28
36
  }
29
- /** Render the framed project line: Project: <path> ... Mode: ask */
37
+ /** Render the project line: Project: <path> ... Mode: ask */
30
38
  function renderProjectBar(width, info) {
31
39
  const label = `${theme_1.THEME.dim}Project:${theme_1.THEME.reset} ${theme_1.THEME.text}${info.project}${theme_1.THEME.reset}`;
32
40
  const modeColor = info.mode === 'yolo' ? theme_1.THEME.warn : theme_1.THEME.muted;
33
41
  const mode = `${theme_1.THEME.dim}Mode:${theme_1.THEME.reset} ${modeColor}${info.mode}${theme_1.THEME.reset}`;
34
- const inner = Math.max(2, width - 2);
35
- const mid = Math.max(1, inner - (0, terminal_1.stripAnsi)(label).length - (0, terminal_1.stripAnsi)(mode).length - 3);
36
- return `${frame}${terminal_1.BOX.v}${theme_1.THEME.reset} ${label}${' '.repeat(mid)}${mode} ${frame}${terminal_1.BOX.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;
37
45
  }
38
- /** Render the framed bottom hint/status bar with live spinner. */
46
+ /** Render the bottom hint/status bar with live spinner. */
39
47
  function renderStatusBar(width, info) {
40
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}`;
41
49
  let right = '';
@@ -45,7 +53,7 @@ function renderStatusBar(width, info) {
45
53
  const conn = info.connected ? `${theme_1.THEME.success}●${theme_1.THEME.reset}` : `${theme_1.THEME.error}○${theme_1.THEME.reset}`;
46
54
  const mode = info.mode === 'yolo' ? `${theme_1.THEME.warn}YOLO${theme_1.THEME.reset}` : `${theme_1.THEME.muted}ask${theme_1.THEME.reset}`;
47
55
  right += `${conn} ${mode}`;
48
- const inner = Math.max(2, width - 2);
49
- const mid = Math.max(1, inner - (0, terminal_1.stripAnsi)(left).length - (0, terminal_1.stripAnsi)(right).length - 3);
50
- return `${frame}${terminal_1.BOX.v}${theme_1.THEME.reset} ${left}${' '.repeat(mid)}${right} ${frame}${terminal_1.BOX.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;
51
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.3",
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",