@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/.claude/settings.local.json +14 -1
- package/CLAUDE.md +2 -1
- package/index.js +15 -1
- package/lib/agent.js +607 -77
- package/lib/api.js +240 -23
- package/lib/commands.js +105 -81
- package/lib/config.js +32 -4
- package/lib/constants.js +67 -1
- package/lib/metrics.js +16 -3
- package/lib/permissions.js +66 -67
- package/lib/prompts.js +97 -83
- package/lib/tool_specs.js +499 -0
- package/lib/tools.js +645 -319
- package/lib/ui/ansi.js +17 -4
- package/lib/ui/chat-history.js +201 -61
- package/lib/ui/create-ui.js +116 -373
- package/lib/ui/diff.js +87 -75
- package/lib/ui/input-field.js +76 -58
- package/lib/ui/status-bar.js +56 -25
- package/lib/ui/terminal.js +58 -0
- package/lib/ui/theme.js +78 -0
- package/lib/ui/utils.js +63 -1
- package/lib/ui/writer.js +255 -0
- package/lib/ui.js +5 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
91
|
+
if (!diff.some((d) => d.type !== 'same')) return ' No changes detected';
|
|
73
92
|
}
|
|
74
93
|
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
? `${
|
|
187
|
+
? `${GUTTER}${P.frame}… ${extra} more lines${R}`
|
|
176
188
|
: `… ${extra} more lines`);
|
|
177
189
|
return result.join('\n');
|
|
178
190
|
}
|
package/lib/ui/input-field.js
CHANGED
|
@@ -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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1021
|
-
|
|
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
|
-
|
|
1056
|
+
this._layout.setInputHeight(1);
|
|
1038
1057
|
}
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
-
|
|
1066
|
+
this._layout.setInputHeight(1);
|
|
1052
1067
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1091
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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
|
}
|
package/lib/ui/status-bar.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
36
|
+
resume() { this._paused = false; this._notify(); }
|
|
25
37
|
|
|
26
38
|
setModel(name) {
|
|
27
39
|
this._model = name || '';
|
|
28
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
87
|
+
this._notify();
|
|
79
88
|
}
|
|
80
89
|
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
+
return left + ' '.repeat(padding) + rightAnsi;
|
|
133
|
+
}
|
|
114
134
|
|
|
115
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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() {
|