@semalt-ai/code 1.8.0 → 1.8.3

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/lib/ui/diff.js CHANGED
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
- const { FG_DARK, FG_RED, FG_GREEN, FG_GRAY, FG_YELLOW, FG_TEAL, RST, THEME } = require('./ansi');
4
- const { getCols, stripAnsi } = require('./utils');
3
+ const { FG_DARK, FG_RED, FG_GREEN, FG_GRAY, FG_YELLOW, FG_TEAL, RST, THEME, EL, hasTruecolor } = require('./ansi');
4
+ const { getCols, stripAnsi, termWidth } = require('./utils');
5
+ const { DIFF_THEME, UI_THEME } = require('./theme');
5
6
 
6
7
  function diffLines(oldLines, newLines) {
7
8
  const m = oldLines.length, n = newLines.length;
@@ -37,29 +38,47 @@ function diffLinesHashed(oldLines, newLines) {
37
38
  return result;
38
39
  }
39
40
 
40
- function renderDiff(oldText, newText, filePath) {
41
+ // Truncate `text` to at most `w` terminal columns. When truncation happens,
42
+ // the last visible column is replaced with '…'. Width is measured in display
43
+ // columns (termWidth), so emoji / CJK consume 2 per char.
44
+ function _truncateByWidth(text, w) {
45
+ if (w <= 0) return '';
46
+ if (termWidth(text) <= w) return text;
47
+ let out = '', used = 0;
48
+ for (const ch of text) {
49
+ const chw = termWidth(ch);
50
+ if (used + chw > w - 1) break;
51
+ out += ch;
52
+ used += chw;
53
+ }
54
+ return out + '…';
55
+ }
56
+
57
+ function renderDiff(oldText, newText, filePath, opts) {
41
58
  if (oldText.includes('\x00') || newText.includes('\x00')) return '[binary file — diff skipped]';
42
59
  const isTTY = process.stdout.isTTY === true;
43
- const cols = getCols();
44
-
45
- // ── Layout constants ───────────────────────────────────────────────────────
46
- const INDENT = ' '; // left offset from main stream
47
- const boxWidth = Math.max(40, cols - INDENT.length);
48
- const innerWidth = boxWidth - 2; // between ╭ and ╮
49
-
50
- const a = (code) => isTTY ? code : '';
51
- const R = a(RST);
52
-
53
- // Palette
54
- const BORDER = a(FG_DARK);
55
- const DEL_BG = a('\x1b[48;5;52m'); // dark red background
56
- const ADD_BG = a('\x1b[48;5;22m'); // dark green background
57
- const DEL_FG = a('\x1b[38;5;203m'); // bright red text (= FG_RED)
58
- const ADD_FG = a('\x1b[38;5;114m'); // bright green text (= FG_GREEN)
59
- const NUM_CLR = a('\x1b[38;5;240m'); // muted line-number gutter
60
- const CTX_CLR = a('\x1b[38;5;245m'); // dim context text
61
- const HUNK_CLR = a('\x1b[38;5;73m'); // teal hunk separator
62
- const PATH_CLR = a('\x1b[38;5;222m'); // yellow filepath in header
60
+ // `inset` reserves N columns for an outer wrapper (e.g. the permission
61
+ // bubble indents continuation lines by 5 spaces). Subtract it from the
62
+ // effective width so our truncated content still fits on one physical row.
63
+ const inset = (opts && Number.isInteger(opts.inset) && opts.inset >= 0) ? opts.inset : 0;
64
+ const cols = Math.max(20, getCols() - inset);
65
+
66
+ // Resolve the palette once per render. Truecolor is used when the terminal
67
+ // advertises it via COLORTERM; otherwise fall back to 256-color.
68
+ const useTC = hasTruecolor();
69
+ const a = (code) => isTTY ? code : '';
70
+ const R = a(RST);
71
+ const EL_ = a(EL);
72
+ const pickBg = (t) => useTC && t.bgTC ? t.bgTC : t.bg256;
73
+ const P = {
74
+ added: { bg: a(pickBg(DIFF_THEME.added)), signFg: a(DIFF_THEME.added.signFg), sign: DIFF_THEME.added.sign },
75
+ removed: { bg: a(pickBg(DIFF_THEME.removed)), signFg: a(DIFF_THEME.removed.signFg), sign: DIFF_THEME.removed.sign },
76
+ context: { bg: '', signFg: '', sign: DIFF_THEME.context.sign },
77
+ ln: a(DIFF_THEME.lineNumber),
78
+ code: a(DIFF_THEME.code),
79
+ hdr: a(DIFF_THEME.header),
80
+ frame: a(DIFF_THEME.frame),
81
+ };
63
82
 
64
83
  const oldLines = oldText === '' ? [] : oldText.split('\n');
65
84
  const newLines = newText.split('\n');
@@ -69,58 +88,58 @@ function renderDiff(oldText, newText, filePath) {
69
88
  if (!isNewFile) {
70
89
  diff = (oldLines.length > 200 || newLines.length > 200)
71
90
  ? diffLinesHashed(oldLines, newLines) : diffLines(oldLines, newLines);
72
- if (!diff.some((d) => d.type !== 'same')) return `${INDENT} No changes detected`;
91
+ if (!diff.some((d) => d.type !== 'same')) return ' No changes detected';
73
92
  }
74
93
 
75
- // Gutter width: enough digits for the largest line number
76
- const maxLn = Math.max(oldLines.length, newLines.length, 1);
77
- const LN_W = Math.max(2, String(maxLn).length);
78
-
79
- // gutter template: ' NNNN │S ' — LN_W + 5 chars
80
- const GUTTER_W = LN_W + 5;
81
- const CONT_W = Math.max(1, innerWidth - GUTTER_W);
82
-
83
- // ── Builders ───────────────────────────────────────────────────────────────
84
-
85
- // One content line inside the box.
86
- // lnStr – line-number string (right-aligned to LN_W, or '' for unknown)
87
- // symbol '-', '+', or ' '
88
- // text – raw source text
89
- // bg – background ANSI string (or '' for context)
90
- // fg – foreground ANSI string
91
- function makeLine(lnStr, symbol, text, bg, fg) {
92
- const num = lnStr ? lnStr.padStart(LN_W) : ' '.repeat(LN_W);
93
- const disp = text.replace(/\t/g, ' ');
94
- const clp = disp.length > CONT_W ? disp.slice(0, CONT_W - 1) + '…' : disp;
95
- const padded = clp.padEnd(CONT_W);
96
- // gutter: ' NUM │S '
97
- const gt = ` ${NUM_CLR}${num}${R}${bg}${BORDER} │${R}${bg}${fg}${symbol} ${padded}${R}`;
98
- return `${INDENT}${BORDER}│${R}${bg}${gt}${R}${BORDER}│${R}`;
94
+ // ── Layout ─────────────────────────────────────────────────────────────────
95
+ // [GUTTER(3) default bg] [LN_W line#] [' '] [sign] [' '] [code] [EL] [RST]
96
+ // The gutter stays in the terminal's default bg so the dark bg on the
97
+ // change lines has a visible left edge — no box frame needed.
98
+ const GUTTER = ' ';
99
+ const maxLn = Math.max(oldLines.length, newLines.length, 1);
100
+ const LN_W = Math.max(2, String(maxLn).length);
101
+ const PREFIX_W = GUTTER.length + LN_W + 3; // gutter + ln + ' ' + sign + ' '
102
+ const CODE_W = Math.max(1, cols - PREFIX_W);
103
+
104
+ // Render a single diff line. For added/removed, the dark bg begins right
105
+ // after the gutter and extends to the physical right edge via EL. Context
106
+ // lines set no bg so EL is skipped too (no-op but cleaner output).
107
+ function makeLine(lnStr, kind, text) {
108
+ const t = P[kind];
109
+ const num = (lnStr || '').padStart(LN_W);
110
+ const disp = (text || '').replace(/\t/g, ' ');
111
+ const code = _truncateByWidth(disp, CODE_W);
112
+
113
+ let s = GUTTER;
114
+ s += t.bg;
115
+ s += P.ln + num;
116
+ s += ' ';
117
+ if (t.signFg) s += t.signFg;
118
+ s += t.sign;
119
+ s += P.code;
120
+ s += ' ';
121
+ s += code;
122
+ if (t.bg) s += EL_;
123
+ s += R;
124
+ return s;
99
125
  }
100
126
 
101
- // Hunk separator: ···· @@ -x,y +a,b @@ ····
102
127
  function hunkSep(label) {
103
- const body = ` ${label} `;
104
- const total = innerWidth - 2; // 2 for leading/trailing ·
105
- const half = Math.max(0, Math.floor((total - body.length) / 2));
106
- const right = Math.max(0, total - body.length - half);
107
- return `${INDENT}${BORDER}│${R}${HUNK_CLR} ${'·'.repeat(half)}${body}${'·'.repeat(right)} ${R}${BORDER}│${R}`;
128
+ return `${GUTTER}${P.frame}${label}${R}`;
108
129
  }
109
130
 
110
131
  const out = [];
132
+ // Minimal file header — bold blue path, no box. The surrounding UI owns
133
+ // any tool-label framing; this line is the only header the diff emits.
134
+ out.push(`${GUTTER}${P.hdr}${filePath}${R}`);
111
135
 
112
- // ── Header ─────────────────────────────────────────────────────────────────
113
- const pathPart = ` ${filePath} `;
114
- const fillRight = Math.max(1, innerWidth - 1 - pathPart.length); // 1 for leading ─
115
- out.push(`${INDENT}${BORDER}╭─${PATH_CLR}${pathPart}${R}${BORDER}${'─'.repeat(fillRight)}╮${R}`);
116
-
117
- // ── Body ───────────────────────────────────────────────────────────────────
118
136
  if (isNewFile) {
119
- out.push(hunkSep('new file'));
137
+ // Meta-label "(new file)" rendered in the shared subtle palette so it
138
+ // recedes relative to the hunk marker itself.
139
+ const subtle = a(UI_THEME.subtle);
140
+ out.push(`${GUTTER}${P.frame}@@ -0,0 +1,${newLines.length} @@ ${R}${subtle}(new file)${R}`);
120
141
  let ln = 1;
121
- for (const line of newLines) {
122
- out.push(makeLine(String(ln++), '+', line, ADD_BG, ADD_FG));
123
- }
142
+ for (const line of newLines) out.push(makeLine(String(ln++), 'added', line));
124
143
  } else {
125
144
  let oldLn = 1, newLn = 1;
126
145
  const annotated = diff.map((d) => {
@@ -153,26 +172,19 @@ function renderDiff(oldText, newText, filePath) {
153
172
  out.push(hunkSep(`@@ -${oldStart},${oldCnt} +${newStart},${newCnt} @@`));
154
173
 
155
174
  for (const e of hunk) {
156
- if (e.type === 'del') {
157
- out.push(makeLine(String(e.oldLine), '-', e.text, DEL_BG, DEL_FG));
158
- } else if (e.type === 'add') {
159
- out.push(makeLine(String(e.newLine), '+', e.text, ADD_BG, ADD_FG));
160
- } else {
161
- out.push(makeLine(String(e.newLine), ' ', e.text, '', CTX_CLR));
162
- }
175
+ if (e.type === 'del') out.push(makeLine(String(e.oldLine), 'removed', e.text));
176
+ else if (e.type === 'add') out.push(makeLine(String(e.newLine), 'added', e.text));
177
+ else out.push(makeLine(String(e.newLine), 'context', e.text));
163
178
  }
164
179
  }
165
180
  }
166
181
 
167
- // ── Footer ─────────────────────────────────────────────────────────────────
168
- out.push(`${INDENT}${BORDER}╰${'─'.repeat(innerWidth)}╯${R}`);
169
-
170
182
  const MAX = 120;
171
183
  if (out.length > MAX) {
172
184
  const extra = out.length - MAX;
173
185
  const result = out.slice(0, MAX);
174
186
  result.push(isTTY
175
- ? `${INDENT}${FG_DARK} … ${extra} more lines${RST}`
187
+ ? `${GUTTER}${P.frame}… ${extra} more lines${R}`
176
188
  : `… ${extra} more lines`);
177
189
  return result.join('\n');
178
190
  }
@@ -4,10 +4,11 @@ const EventEmitter = require('events');
4
4
  const readline = require('readline');
5
5
 
6
6
  const { RST, DIM, FG_CYAN, FG_GREEN, FG_YELLOW } = require('./ansi');
7
+ const writer = require('./writer');
7
8
 
8
9
  const SLASH_CMDS = [
9
10
  '/help','/file','/new','/model','/models','/shell','/compact',
10
- '/clear','/approve','/config','/history','/login','/whoami','/logout','/chats',
11
+ '/clear','/approve','/debug','/config','/history','/login','/whoami','/logout','/chats',
11
12
  ];
12
13
 
13
14
  // ─── Key sequence parser ──────────────────────────────────────────────────────
@@ -107,10 +108,15 @@ function parseKeySequence(buf) {
107
108
  // ─── InputField ───────────────────────────────────────────────────────────────
108
109
 
109
110
  class InputField extends EventEmitter {
110
- constructor(layout, chatHistory) {
111
+ constructor(layout, chatHistory, onChange) {
111
112
  super();
112
113
  this._layout = layout;
113
114
  this._chatHistory = chatHistory;
115
+ this._onChange = typeof onChange === 'function' ? onChange : () => {};
116
+ // Cached render output — re-computed on every _render()/_renderHints()
117
+ // call. Consumed by the UI orchestrator to build the live region.
118
+ this._inputLines = [`${DIM}›${RST}`];
119
+ this._hintsText = '';
114
120
  this._chars = [];
115
121
  this._cursor = 0;
116
122
  this._disabled = false;
@@ -156,9 +162,16 @@ class InputField extends EventEmitter {
156
162
  if (process.stdin.isTTY) {
157
163
  process.stdin.setRawMode(true);
158
164
  process.stdin.resume();
159
- process.stdout.write('\x1b[?2004h'); // bracketed paste
160
- process.stdout.write('\x1b[>4;2m'); // xterm modifyOtherKeys level 2
161
- process.stdout.write('\x1b[>1u'); // kitty keyboard protocol
165
+ // Terminal-mode toggles go through the writer queue so they can't
166
+ // land inside another task's compound write. Each is a tiny escape
167
+ // that only toggles state — no display content.
168
+ writer.enqueue(() => {
169
+ try {
170
+ process.stdout.write('\x1b[?2004h'); // bracketed paste
171
+ process.stdout.write('\x1b[>4;2m'); // xterm modifyOtherKeys level 2
172
+ process.stdout.write('\x1b[>1u'); // kitty keyboard protocol
173
+ } catch {}
174
+ });
162
175
  process.stdin.on('data', this._onData);
163
176
  this._idleTimer = setTimeout(() => this._goIdle(), 0);
164
177
  }
@@ -188,14 +201,16 @@ class InputField extends EventEmitter {
188
201
  hideCursor() {
189
202
  if (!this._cursorHidden) {
190
203
  this._cursorHidden = true;
191
- process.stdout.write('\x1b[?25l');
204
+ // Serialize through writer so this can't interleave mid-frame with a
205
+ // bubble/scrollback write.
206
+ writer.enqueue(() => { try { process.stdout.write('\x1b[?25l'); } catch {} });
192
207
  }
193
208
  }
194
209
 
195
210
  showCursor() {
196
211
  if (this._cursorHidden) {
197
212
  this._cursorHidden = false;
198
- process.stdout.write('\x1b[?25h');
213
+ writer.enqueue(() => { try { process.stdout.write('\x1b[?25h'); } catch {} });
199
214
  }
200
215
  }
201
216
 
@@ -836,7 +851,9 @@ class InputField extends EventEmitter {
836
851
  if (this._selectCapture) { this._processBuf(buf, false); return; }
837
852
  if (this._navCapture) { this._processBuf(buf, false); return; }
838
853
  if (this._disabled) {
839
- if (buf[0] === 0x03 || buf[0] === 0x04) {
854
+ if (buf[0] === 0x03) {
855
+ this.emit('abort');
856
+ } else if (buf[0] === 0x04) {
840
857
  this.emit('interrupt');
841
858
  } else if (buf[0] === 0x0f) {
842
859
  this.emit('expand');
@@ -995,18 +1012,16 @@ class InputField extends EventEmitter {
995
1012
  }
996
1013
 
997
1014
  // ── Hints row ────────────────────────────────────────────────────────────────
1015
+ //
1016
+ // Build the hints-line string and publish the updated live region. No
1017
+ // direct stdout writes here — the shared writer owns the terminal.
998
1018
 
999
1019
  _renderHints() {
1000
- const layout = this._layout;
1001
- if (!layout || !layout.hintsRow) return;
1002
- const row = layout.hintsRow;
1003
- const cols = layout.cols;
1004
-
1005
1020
  let text;
1006
1021
  if (this._searchMode) {
1007
1022
  text = `${FG_YELLOW}ctrl+r${RST}${DIM} next match ${RST}${FG_YELLOW}esc${RST}${DIM} cancel search${RST}`;
1008
1023
  } else if (this._disabled) {
1009
- text = `${FG_YELLOW}esc${RST}${DIM} interrupt${RST}`;
1024
+ text = `${FG_YELLOW}esc${RST}${DIM}/${RST}${FG_YELLOW}ctrl+c${RST}${DIM} interrupt${RST}`;
1010
1025
  } else if (this._navCapture) {
1011
1026
  if (this._navSearchMode) {
1012
1027
  text = `${FG_YELLOW}search:${RST} ${FG_CYAN}'${this._navSearchQuery}'${RST} ${DIM}↑↓ navigate enter select esc clear${RST}`;
@@ -1017,42 +1032,41 @@ class InputField extends EventEmitter {
1017
1032
  text = `${DIM}↑↓ history ${RST}${FG_CYAN}ctrl+r${RST}${DIM} search ${RST}${FG_CYAN}ctrl+c${RST}${DIM} cancel${RST}`;
1018
1033
  }
1019
1034
 
1020
- // Use DECSC/DECRC so the cursor is restored after drawing (keeps streaming
1021
- // tokens anchored at historyRows when the blink timer calls this).
1022
- process.stdout.write(`\x1b7\x1b[${row};1H\x1b[2K${text}`);
1023
- const visLen = text.replace(/\x1b\[[^m]*m/g, '').length;
1024
- if (visLen < cols) process.stdout.write(' '.repeat(cols - visLen));
1025
- process.stdout.write('\x1b8');
1035
+ this._hintsText = text;
1036
+ this._notify();
1026
1037
  }
1027
1038
 
1039
+ // Expose the last-computed hints line for the orchestrator.
1040
+ renderHintsLine() { return this._hintsText || ''; }
1041
+
1028
1042
  // ── Render ───────────────────────────────────────────────────────────────────
1043
+ //
1044
+ // Compute the rendered input rows and cache them in this._inputLines for
1045
+ // the UI orchestrator. Then _notify() — the orchestrator pulls the lines
1046
+ // and repaints the live region.
1047
+ //
1048
+ // The "caret" is a printable inverted/underlined character embedded in
1049
+ // the text; the OS cursor stays hidden for the whole TUI session so it
1050
+ // never flickers during in-place redraws.
1029
1051
 
1030
1052
  _render() {
1031
- const layout = this._layout;
1032
- const cols = layout.cols;
1033
-
1034
1053
  if (this._disabled) {
1035
1054
  if (this._inputHeight !== 1) {
1036
1055
  this._inputHeight = 1;
1037
- layout.setInputHeight(1);
1056
+ this._layout.setInputHeight(1);
1038
1057
  }
1039
- const baseRow = layout.inputRow;
1040
- // DECSC/DECRC keeps the cursor in the history scroll region so streaming
1041
- // tokens written after this blink-timer redraw land at the right row.
1042
- process.stdout.write(`\x1b7\x1b[${baseRow};1H\x1b[2K${DIM}›${RST}`);
1043
- process.stdout.write(' '.repeat(Math.max(0, cols - 1)));
1044
- process.stdout.write('\x1b8');
1058
+ this._inputLines = [`${DIM}›${RST}`];
1059
+ this._notify();
1045
1060
  return;
1046
1061
  }
1047
1062
 
1048
1063
  if (this._navCapture) {
1049
1064
  if (this._inputHeight !== 1) {
1050
1065
  this._inputHeight = 1;
1051
- layout.setInputHeight(1);
1066
+ this._layout.setInputHeight(1);
1052
1067
  }
1053
- const baseRow = layout.inputRow;
1054
- process.stdout.write(`\x1b[${baseRow};1H\x1b[2K${DIM}›${RST}`);
1055
- process.stdout.write(' '.repeat(Math.max(0, cols - 1)));
1068
+ this._inputLines = [`${DIM}›${RST}`];
1069
+ this._notify();
1056
1070
  return;
1057
1071
  }
1058
1072
 
@@ -1060,20 +1074,17 @@ class InputField extends EventEmitter {
1060
1074
  if (this._searchMode) {
1061
1075
  if (this._inputHeight !== 1) {
1062
1076
  this._inputHeight = 1;
1063
- layout.setInputHeight(1);
1077
+ this._layout.setInputHeight(1);
1064
1078
  }
1065
- const baseRow = layout.inputRow;
1066
1079
  const noMatch = this._searchQuery && this._searchMatchIdx === -1;
1067
1080
  const queryColor = noMatch ? '\x1b[38;5;203m' : FG_CYAN;
1068
1081
  const prefix = `${DIM}(search)${RST} ${queryColor}'${this._searchQuery}'${RST}${DIM}:${RST} `;
1069
1082
  const matched = this._chars.join('');
1070
1083
 
1071
- // Type label
1072
1084
  const typeLabel = this._searchMatchType
1073
1085
  ? `${DIM}[${this._searchMatchType}]${RST} `
1074
1086
  : '';
1075
1087
 
1076
- // Highlight the matching part (case-insensitive)
1077
1088
  let displayMatch;
1078
1089
  const matchedLow = matched.toLowerCase();
1079
1090
  const qLow = this._searchQuery.toLowerCase();
@@ -1087,10 +1098,8 @@ class InputField extends EventEmitter {
1087
1098
  displayMatch = matched;
1088
1099
  }
1089
1100
 
1090
- const line = prefix + typeLabel + displayMatch;
1091
- process.stdout.write(`\x1b[${baseRow};1H\x1b[2K${line}`);
1092
- const visLen = line.replace(/\x1b\[[^m]*m/g, '').length;
1093
- if (visLen < cols) process.stdout.write(' '.repeat(cols - visLen));
1101
+ this._inputLines = [prefix + typeLabel + displayMatch];
1102
+ this._notify();
1094
1103
  return;
1095
1104
  }
1096
1105
 
@@ -1101,13 +1110,7 @@ class InputField extends EventEmitter {
1101
1110
  const needed = Math.min(5, newlineCount + 1);
1102
1111
  if (needed !== this._inputHeight) {
1103
1112
  this._inputHeight = needed;
1104
- layout.setInputHeight(needed);
1105
- }
1106
-
1107
- const baseRow = layout.inputRow;
1108
-
1109
- for (let r = baseRow; r < baseRow + this._inputHeight; r++) {
1110
- process.stdout.write(`\x1b[${r};1H\x1b[2K`);
1113
+ this._layout.setInputHeight(needed);
1111
1114
  }
1112
1115
 
1113
1116
  const PREFIX = '\x1b[36m › \x1b[0m';
@@ -1129,9 +1132,9 @@ class InputField extends EventEmitter {
1129
1132
  pos += lines[li].length + 1;
1130
1133
  }
1131
1134
 
1135
+ const out = [];
1132
1136
  const renderCount = Math.min(lines.length, this._inputHeight);
1133
1137
  for (let li = 0; li < renderCount; li++) {
1134
- const r = baseRow + li;
1135
1138
  const lineChars = lines[li];
1136
1139
 
1137
1140
  let lineStr;
@@ -1149,11 +1152,19 @@ class InputField extends EventEmitter {
1149
1152
 
1150
1153
  const leadIn = li === 0 ? PREFIX : ' '.repeat(PREFIX_LEN);
1151
1154
  const hintStr = (li === 0 && this._hint) ? `${DIM}${this._hint}${RST}` : '';
1152
- const visLen = PREFIX_LEN + lineChars.length + (li === 0 ? this._hint.length : 0);
1153
- const pad = visLen < cols ? ' '.repeat(cols - visLen) : '';
1154
-
1155
- process.stdout.write(`\x1b[${r};1H` + leadIn + lineStr + hintStr + pad);
1155
+ out.push(leadIn + lineStr + hintStr);
1156
1156
  }
1157
+
1158
+ if (out.length === 0) out.push(PREFIX);
1159
+ this._inputLines = out;
1160
+ this._notify();
1161
+ }
1162
+
1163
+ // Expose the last-computed input lines for the orchestrator.
1164
+ renderInputLines() { return this._inputLines.slice(); }
1165
+
1166
+ _notify() {
1167
+ try { this._onChange(); } catch {}
1157
1168
  }
1158
1169
 
1159
1170
  // ── Destroy ──────────────────────────────────────────────────────────────────
@@ -1164,10 +1175,17 @@ class InputField extends EventEmitter {
1164
1175
  if (this._idleTimer) { clearTimeout(this._idleTimer); this._idleTimer = null; }
1165
1176
  if (process.stdin.isTTY) {
1166
1177
  process.stdin.removeListener('data', this._onData);
1167
- this._cursorHidden = true; this.showCursor(); // restore cursor visibility
1168
- process.stdout.write('\x1b[<u'); // pop kitty keyboard protocol
1169
- process.stdout.write('\x1b[>4;0m'); // reset modifyOtherKeys
1170
- process.stdout.write('\x1b[?2004l'); // disable bracketed paste
1178
+ // Terminal-mode pops must run in queue order so they don't race with
1179
+ // a pending live-region redraw on shutdown.
1180
+ writer.enqueue(() => {
1181
+ try {
1182
+ process.stdout.write('\x1b[?25h'); // show cursor
1183
+ process.stdout.write('\x1b[<u'); // pop kitty keyboard protocol
1184
+ process.stdout.write('\x1b[>4;0m'); // reset modifyOtherKeys
1185
+ process.stdout.write('\x1b[?2004l'); // disable bracketed paste
1186
+ } catch {}
1187
+ });
1188
+ this._cursorHidden = false;
1171
1189
  process.stdin.setRawMode(false); // restore cooked mode so mouse works again
1172
1190
  }
1173
1191
  }
@@ -1,15 +1,23 @@
1
1
  'use strict';
2
2
 
3
- const { RST, DIM, FG_RED, SPINNER_DEFS } = require('./ansi');
3
+ const { RST, DIM, FG_RED, FG_DARK, SPINNER_DEFS } = require('./ansi');
4
4
  const { stripAnsi } = require('./utils');
5
5
 
6
+ // Status bar is a *content producer* only. It builds a single line string
7
+ // and hands it to the UI orchestrator via the onChange callback; the
8
+ // orchestrator composes the full live region (status + input + hints) and
9
+ // pushes it through the shared writer in one serialized burst. No direct
10
+ // stdout writes happen here, so the status-bar timer can't interleave
11
+ // mid-bubble.
6
12
  class FullStatusBar {
7
- constructor(layout) {
13
+ constructor(layout, onChange) {
8
14
  this._layout = layout;
15
+ this._onChange = typeof onChange === 'function' ? onChange : () => {};
9
16
  this._state = 'idle';
10
17
  this._label = 'Ready';
11
18
  this._model = '';
12
19
  this._totalTokens = 0;
20
+ this._tokenLimit = null;
13
21
  this._speed = 0;
14
22
  this._streamStart = null;
15
23
  this._streamTokens = 0;
@@ -17,23 +25,24 @@ class FullStatusBar {
17
25
  this._animIdx = 0;
18
26
  this._animTimer = null;
19
27
  this._paused = false;
20
- this._clockTimer = setInterval(() => this._renderBar(), 1000);
28
+ // Clock tick drives the `HH:MM:SS` part of the right-hand side. Every
29
+ // tick just notifies the orchestrator to re-push the live region — the
30
+ // compound erase+redraw goes through the writer's queue so a tick
31
+ // falling mid-bubble can't produce a torn frame.
32
+ this._clockTimer = setInterval(() => this._notify(), 1000);
21
33
  }
22
34
 
23
35
  pause() { this._paused = true; }
24
- resume() { this._paused = false; this._renderBar(); }
36
+ resume() { this._paused = false; this._notify(); }
25
37
 
26
38
  setModel(name) {
27
39
  this._model = name || '';
28
- this._renderBar();
40
+ this._notify();
29
41
  }
30
42
 
31
43
  update(state, label) {
32
44
  this._state = state || 'idle';
33
45
  if (label !== undefined) this._label = label;
34
-
35
- // An explicit update must always be visible — unpause so _renderBar() runs.
36
- // The pause flag only suppresses timer-driven clock/spinner redraws.
37
46
  this._paused = false;
38
47
 
39
48
  if (state === 'streaming') {
@@ -42,14 +51,13 @@ class FullStatusBar {
42
51
  this._streamStart = null; this._streamTokens = 0; this._speed = 0;
43
52
  }
44
53
 
45
- const animStates = ['thinking', 'streaming', 'tool'];
54
+ const animStates = ['thinking', 'streaming', 'tool', 'waiting_download'];
46
55
  if (animStates.includes(state) && !this._animTimer) {
47
- this._animTimer = setInterval(() => { this._animIdx++; this._renderBar(); }, 100);
56
+ this._animTimer = setInterval(() => { this._animIdx++; this._notify(); }, 100);
48
57
  } else if (!animStates.includes(state) && this._animTimer) {
49
58
  clearInterval(this._animTimer); this._animTimer = null; this._animIdx = 0;
50
59
  }
51
- if (state === 'idle') this.drawSeparator();
52
- this._renderBar();
60
+ this._notify();
53
61
  }
54
62
 
55
63
  onToken() {
@@ -61,13 +69,14 @@ class FullStatusBar {
61
69
  const elapsed = (Date.now() - this._streamStart) / 1000;
62
70
  this._speed = elapsed > 0 ? Math.round(this._streamTokens / elapsed) : 0;
63
71
  }
64
- this._renderBar();
72
+ this._notify();
65
73
  }
66
74
  }
67
75
 
68
76
  updateMetrics(data) {
69
77
  if (data && data.totalTokens !== undefined) this._totalTokens = data.totalTokens;
70
- this._renderBar();
78
+ if (data && 'tokenLimit' in data) this._tokenLimit = data.tokenLimit;
79
+ this._notify();
71
80
  }
72
81
 
73
82
  liveUpdate(fields) {
@@ -75,14 +84,16 @@ class FullStatusBar {
75
84
  const n = parseInt(String(fields.tokens), 10);
76
85
  if (!isNaN(n)) this._totalTokens = n;
77
86
  }
78
- this._renderBar();
87
+ this._notify();
79
88
  }
80
89
 
81
- _renderBar() {
82
- if (this._paused) return;
90
+ // Render the status bar as a single line string. Used by the UI composer
91
+ // to build the live region. Returns null when paused so the composer can
92
+ // omit the row entirely.
93
+ renderLine() {
94
+ if (this._paused) return null;
83
95
  const layout = this._layout;
84
96
  const cols = layout.cols;
85
- const row = layout.statusRow;
86
97
  let left = '';
87
98
  const state = this._state;
88
99
 
@@ -102,7 +113,15 @@ class FullStatusBar {
102
113
  const timePart = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
103
114
  const rightParts = [timePart];
104
115
  if (this._model) rightParts.push(this._model);
105
- if (this._totalTokens > 0) rightParts.push(`${this._totalTokens.toLocaleString()} tok`);
116
+ const liveTokens = this._totalTokens + this._streamTokens;
117
+ rightParts.push(`${liveTokens.toLocaleString()} tok`);
118
+ if (this._tokenLimit) {
119
+ if (this._tokenLimit.limit === null) {
120
+ rightParts.push('limit unknown');
121
+ } else {
122
+ rightParts.push(`${this._tokenLimit.used.toLocaleString()}/${this._tokenLimit.limit.toLocaleString()} (${this._tokenLimit.pct}%)`);
123
+ }
124
+ }
106
125
  if (state === 'streaming' && this._speed > 0) rightParts.push(`${this._speed} t/s`);
107
126
 
108
127
  const rightVisible = rightParts.join(' · ');
@@ -110,15 +129,27 @@ class FullStatusBar {
110
129
  const leftLen = stripAnsi(left).length;
111
130
  const rightLen = rightVisible.length;
112
131
  const padding = Math.max(1, cols - leftLen - rightLen);
113
- const line = left + ' '.repeat(padding) + rightAnsi;
132
+ return left + ' '.repeat(padding) + rightAnsi;
133
+ }
114
134
 
115
- process.stdout.write(`\x1b7\x1b[${row};1H\x1b[2K${line}\x1b8`);
135
+ // Thin horizontal rule rendered above the status line. Kept as its own
136
+ // line in the live region so the composer can turn it on/off without
137
+ // affecting surrounding rows.
138
+ renderSeparator() {
139
+ const cols = this._layout.cols;
140
+ return `${FG_DARK}${'─'.repeat(Math.max(0, cols - 1))}${RST}`;
116
141
  }
117
142
 
118
- drawSeparator() {
119
- const layout = this._layout;
120
- const { FG_DARK } = require('./ansi');
121
- process.stdout.write(`\x1b7\x1b[${layout.separatorRow};1H\x1b[2K${FG_DARK}${'─'.repeat(layout.cols)}${RST}\x1b8`);
143
+ // Back-compat: older call sites (command.js, create-ui.js) call
144
+ // drawSeparator/_renderBar after every state change. These now just
145
+ // trigger a live-region refresh — the orchestrator composes the full
146
+ // region and serializes the write.
147
+ drawSeparator() { this._notify(); }
148
+ _renderBar() { this._notify(); }
149
+
150
+ _notify() {
151
+ if (this._paused) { this._onChange(); return; }
152
+ this._onChange();
122
153
  }
123
154
 
124
155
  destroy() {