zames_pro 2.50.1 → 2.51.0
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/dist/commands.js +52 -45
- package/dist/config-menu.js +150 -23
- package/dist/i18n.js +132 -0
- package/dist/index.js +67 -21
- package/dist/input.js +165 -21
- package/dist/self-review.js +2 -2
- package/dist/spinner.js +5 -10
- package/dist/theme.js +10 -1
- package/package.json +1 -1
package/dist/commands.js
CHANGED
|
@@ -1,29 +1,26 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import { isImageName } from './attachments.js';
|
|
3
|
+
import { translate } from './i18n.js';
|
|
3
4
|
// Helpers for the extra slash commands (/diff, /cost, /export, /doctor,
|
|
4
5
|
// /permissions, /review, /add-dir). Pure functions, unit-tested without a
|
|
5
6
|
// live agent/browser. index.ts only renders their output.
|
|
6
7
|
const NL = String.fromCharCode(10);
|
|
7
8
|
const CR = String.fromCharCode(13);
|
|
8
9
|
// ---------- /diff ----------
|
|
9
|
-
export function formatDiff(diffText, opts = {}) {
|
|
10
|
+
export function formatDiff(diffText, opts = {}, t = translate('en')) {
|
|
10
11
|
const maxLines = opts.maxLines ?? 200;
|
|
11
12
|
const trimmed = String(diffText ?? '')
|
|
12
13
|
.split(CR + NL)
|
|
13
14
|
.join(NL)
|
|
14
15
|
.trimEnd();
|
|
15
16
|
if (!trimmed)
|
|
16
|
-
return '
|
|
17
|
+
return t('diff.no_changes');
|
|
17
18
|
const lines = trimmed.split(NL);
|
|
18
19
|
if (lines.length <= maxLines)
|
|
19
20
|
return trimmed;
|
|
20
21
|
const head = lines.slice(0, maxLines).join(NL);
|
|
21
22
|
const rest = lines.length - maxLines;
|
|
22
|
-
return
|
|
23
|
-
NL +
|
|
24
|
-
'... [' +
|
|
25
|
-
rest +
|
|
26
|
-
' more lines, use Bash git diff for the full output]');
|
|
23
|
+
return head + NL + t('diff.more_lines', { n: String(rest) });
|
|
27
24
|
}
|
|
28
25
|
export function diffGitArgs(staged = false) {
|
|
29
26
|
return staged ? 'git diff --staged' : 'git diff';
|
|
@@ -89,30 +86,30 @@ export function formatDuration(ms, units = EN_DURATION_UNITS) {
|
|
|
89
86
|
return m + units.m + ' ' + pad(s) + units.s;
|
|
90
87
|
return s + units.s;
|
|
91
88
|
}
|
|
92
|
-
export function renderCost(stats, transcriptFile, tokenUsage = null) {
|
|
89
|
+
export function renderCost(stats, transcriptFile, tokenUsage = null, t = translate('en')) {
|
|
93
90
|
const lines = [];
|
|
94
|
-
lines.push('
|
|
91
|
+
lines.push(t('cost.title'));
|
|
95
92
|
if (typeof tokenUsage === 'number') {
|
|
96
|
-
lines.push('
|
|
93
|
+
lines.push(t('cost.context_known', { n: String(tokenUsage) }));
|
|
97
94
|
}
|
|
98
95
|
else {
|
|
99
|
-
lines.push(
|
|
96
|
+
lines.push(t('cost.context_unknown'));
|
|
100
97
|
}
|
|
101
|
-
lines.push('
|
|
102
|
-
lines.push('
|
|
98
|
+
lines.push(t('cost.tasks', { n: String(stats.turns) }));
|
|
99
|
+
lines.push(t('cost.tool_calls', { n: String(stats.toolCalls) }));
|
|
103
100
|
if (stats.autoCompacts > 0) {
|
|
104
|
-
lines.push('
|
|
101
|
+
lines.push(t('cost.auto_compacts', { n: String(stats.autoCompacts) }));
|
|
105
102
|
}
|
|
106
103
|
const top = Object.entries(stats.toolCounts).sort((a, b) => b[1] - a[1]);
|
|
107
104
|
if (top.length) {
|
|
108
|
-
lines.push('
|
|
105
|
+
lines.push(t('cost.by_tool'));
|
|
109
106
|
for (const [name, n] of top)
|
|
110
107
|
lines.push(' ' + name + ': ' + n);
|
|
111
108
|
}
|
|
112
|
-
lines.push('
|
|
109
|
+
lines.push(t('cost.duration', { dur: formatDuration(stats.durationMs) }));
|
|
113
110
|
if (stats.startedAt)
|
|
114
|
-
lines.push('
|
|
115
|
-
lines.push('
|
|
111
|
+
lines.push(t('cost.started', { v: stats.startedAt }));
|
|
112
|
+
lines.push(t('cost.transcript', { v: transcriptFile || t('cost.off') }));
|
|
116
113
|
return lines.join(NL);
|
|
117
114
|
}
|
|
118
115
|
// ---------- /export ----------
|
|
@@ -172,64 +169,74 @@ export function defaultExportPath(workdir, now = new Date()) {
|
|
|
172
169
|
.replace('Z', '');
|
|
173
170
|
return path.join(workdir, 'zames-export-' + stamp + '.md');
|
|
174
171
|
}
|
|
175
|
-
export function renderDoctor(d) {
|
|
172
|
+
export function renderDoctor(d, t = translate('en')) {
|
|
176
173
|
const rows = [];
|
|
177
174
|
const row = (ok, label, value) => {
|
|
178
175
|
rows.push(' ' + (ok ? '[OK] ' : '[WARN]') + ' ' + label.padEnd(16) + ' ' + value);
|
|
179
176
|
};
|
|
177
|
+
const onoff = (b) => (b ? t('common.on') : t('common.off'));
|
|
180
178
|
row(true, 'node', d.nodeVersion);
|
|
181
179
|
row(true, 'platform', d.platform);
|
|
182
180
|
row(true, 'workdir', d.workdir);
|
|
183
|
-
row(d.gitOk, 'git', d.gitOk
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
row(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
row(
|
|
181
|
+
row(d.gitOk, 'git', d.gitOk
|
|
182
|
+
? t('doctor.repo', { v: d.gitBranch || t('doctor.detached') })
|
|
183
|
+
: t('doctor.not_repo'));
|
|
184
|
+
row(d.configOk, 'config', d.configOk
|
|
185
|
+
? t('doctor.loaded')
|
|
186
|
+
: t('doctor.error', { v: d.configError || t('doctor.unknown') }));
|
|
187
|
+
row(true, 'browser', d.browserChannel ? d.browserChannel : t('doctor.bundled'));
|
|
188
|
+
row(!!d.authSaved, 'auth', d.authSaved ? t('doctor.auth_saved') : t('doctor.auth_none'));
|
|
189
|
+
row(!!d.clipboardTool, 'clipboard', d.clipboardTool || t('doctor.clipboard_none'));
|
|
190
|
+
row(true, 'mcp', t('doctor.mcp', {
|
|
191
|
+
servers: String(d.mcpServers),
|
|
192
|
+
tools: String(d.mcpTools),
|
|
193
|
+
}));
|
|
194
|
+
row(d.transcriptOk, 'transcript', onoff(d.transcriptOk));
|
|
190
195
|
if (typeof d.contextLimit === 'number' && d.contextLimit > 0) {
|
|
191
|
-
row(true, 'context',
|
|
196
|
+
row(true, 'context', t('doctor.tokens', {
|
|
197
|
+
v: d.contextLimit.toLocaleString('en-US'),
|
|
198
|
+
}));
|
|
192
199
|
}
|
|
193
200
|
if (d.gitOk && typeof d.hasOrigin === 'boolean') {
|
|
194
|
-
row(true, 'git remote', d.hasOrigin ? '
|
|
201
|
+
row(true, 'git remote', d.hasOrigin ? t('doctor.origin_ok') : t('doctor.origin_none'));
|
|
195
202
|
}
|
|
196
203
|
if (typeof d.minSendIntervalMs === 'number') {
|
|
197
|
-
row(d.minSendIntervalMs > 0, 'send pause',
|
|
204
|
+
row(d.minSendIntervalMs > 0, 'send pause', t('doctor.send_pause', {
|
|
205
|
+
n: String(Math.round(d.minSendIntervalMs / 1000)),
|
|
206
|
+
}));
|
|
198
207
|
}
|
|
199
208
|
if (typeof d.sshRemote === 'boolean') {
|
|
200
|
-
row(true, 'session', d.sshRemote ? '
|
|
209
|
+
row(true, 'session', d.sshRemote ? t('doctor.ssh') : t('doctor.local'));
|
|
201
210
|
}
|
|
202
211
|
if (d.headless) {
|
|
203
|
-
row(!!d.headlessUaCached, 'headless', d.headlessUaCached
|
|
204
|
-
? 'UA cached (no relaunch)'
|
|
205
|
-
: 'no cached UA yet (first start relaunches once)');
|
|
212
|
+
row(!!d.headlessUaCached, 'headless', d.headlessUaCached ? t('doctor.ua_cached') : t('doctor.ua_not_cached'));
|
|
206
213
|
}
|
|
207
|
-
return '
|
|
214
|
+
return t('doctor.title') + NL + rows.join(NL);
|
|
208
215
|
}
|
|
209
|
-
export function renderPermissions(p) {
|
|
210
|
-
const onoff = (b) => (b ? 'ask' : 'allow');
|
|
216
|
+
export function renderPermissions(p, t = translate('en')) {
|
|
217
|
+
const onoff = (b) => (b ? t('perm.ask') : t('perm.allow'));
|
|
211
218
|
const lines = [];
|
|
212
|
-
lines.push(
|
|
213
|
-
lines.push('
|
|
214
|
-
lines.push('
|
|
215
|
-
lines.push('
|
|
219
|
+
lines.push(t('perm.title'));
|
|
220
|
+
lines.push(t('perm.write', { v: onoff(p.write) }));
|
|
221
|
+
lines.push(t('perm.edit', { v: onoff(p.edit) }));
|
|
222
|
+
lines.push(t('perm.bash', { v: onoff(p.bash) }));
|
|
216
223
|
if (p.alwaysConfirm.length) {
|
|
217
|
-
lines.push(
|
|
224
|
+
lines.push(t('perm.always'));
|
|
218
225
|
for (const re of p.alwaysConfirm)
|
|
219
226
|
lines.push(' ' + re);
|
|
220
227
|
}
|
|
221
228
|
lines.push('');
|
|
222
|
-
lines.push('
|
|
229
|
+
lines.push(t('perm.change_hint'));
|
|
223
230
|
return lines.join(NL);
|
|
224
231
|
}
|
|
225
232
|
// ---------- /add-dir ----------
|
|
226
|
-
export function resolveExtraDir(input, workdir) {
|
|
233
|
+
export function resolveExtraDir(input, workdir, t = translate('en')) {
|
|
227
234
|
const raw = String(input ?? '').trim();
|
|
228
235
|
if (!raw)
|
|
229
|
-
return { error: '
|
|
236
|
+
return { error: t('adddir.usage') };
|
|
230
237
|
const abs = path.resolve(workdir, raw);
|
|
231
238
|
if (abs === path.resolve(workdir)) {
|
|
232
|
-
return { error: '
|
|
239
|
+
return { error: t('adddir.same') };
|
|
233
240
|
}
|
|
234
241
|
return { path: abs };
|
|
235
242
|
}
|
package/dist/config-menu.js
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
import { theme } from './theme.js';
|
|
12
12
|
import { visLen } from './input.js';
|
|
13
13
|
const ESC = String.fromCharCode(27);
|
|
14
|
-
// Strip
|
|
14
|
+
// Strip ANSH sequences to compute the visible length of a line.
|
|
15
15
|
function stripAnsi(s) {
|
|
16
|
-
return s.replace(/\x1b
|
|
16
|
+
return s.replace(/\x1b\`[0-9;?]*[A-Za-z]/g, '');
|
|
17
17
|
}
|
|
18
18
|
// Visible COLUMNS of a line (ANSI removed, wide chars counted as 2). Using the
|
|
19
19
|
// code-point count made the block height disagree with the real screen on lines
|
|
@@ -35,7 +35,7 @@ function displayValue(field, value, t) {
|
|
|
35
35
|
return String(value);
|
|
36
36
|
}
|
|
37
37
|
function groupLabel(field, t) {
|
|
38
|
-
return t(field.groupKey);
|
|
38
|
+
return theme.system(' ' + t(field.groupKey));
|
|
39
39
|
}
|
|
40
40
|
/**
|
|
41
41
|
* Show the interactive menu. Returns a promise that resolves when the
|
|
@@ -45,13 +45,20 @@ function groupLabel(field, t) {
|
|
|
45
45
|
export function runConfigMenu(opts) {
|
|
46
46
|
const input = opts.input || process.stdin;
|
|
47
47
|
const output = opts.output || process.stdout;
|
|
48
|
-
const { fields, t, get, set, reset } = opts;
|
|
48
|
+
const { fields, t, get, set, reset, getDefault } = opts;
|
|
49
49
|
if (!input.isTTY || !input.setRawMode) {
|
|
50
50
|
return Promise.reject(new Error(t('cfg.menu.notty')));
|
|
51
51
|
}
|
|
52
|
+
// cursor indexes the VISIBLE (filtered) list, not the full `fields`.
|
|
52
53
|
let cursor = 0;
|
|
53
54
|
let editing = false;
|
|
54
55
|
let editBuf = '';
|
|
56
|
+
// Reverse/forward filter over the list (/ to enter, typing narrows it).
|
|
57
|
+
let filterMode = false;
|
|
58
|
+
let filterQuery = '';
|
|
59
|
+
// Second `d` on the same row confirms the reset, so a stray keypress
|
|
60
|
+
// cannot wipe a setting by accident.
|
|
61
|
+
let pendingReset = null;
|
|
55
62
|
let message = null;
|
|
56
63
|
let exited = false;
|
|
57
64
|
let resolveDone = null;
|
|
@@ -59,18 +66,53 @@ export function runConfigMenu(opts) {
|
|
|
59
66
|
input.setRawMode(true);
|
|
60
67
|
input.resume();
|
|
61
68
|
output.write(ESC + '[?25l'); // hide the cursor while rendering
|
|
69
|
+
// Fields that match the current filter (path, label or group contains the
|
|
70
|
+
// query, case-insensitive). Empty query -> everything.
|
|
71
|
+
const visible = () => {
|
|
72
|
+
const q = filterQuery.trim().toLowerCase();
|
|
73
|
+
if (!q)
|
|
74
|
+
return fields;
|
|
75
|
+
return fields.filter((f) => (f.path + ' ' + t(f.labelKey || f.path) + ' ' + t(f.groupKey))
|
|
76
|
+
.toLowerCase()
|
|
77
|
+
.includes(q));
|
|
78
|
+
};
|
|
79
|
+
const currentField = () => {
|
|
80
|
+
const vis = visible();
|
|
81
|
+
if (!vis.length)
|
|
82
|
+
return null;
|
|
83
|
+
if (cursor >= vis.length)
|
|
84
|
+
cursor = vis.length - 1;
|
|
85
|
+
return vis[cursor];
|
|
86
|
+
};
|
|
87
|
+
// True when the value differs from the default (the field is overridden
|
|
88
|
+
// in a config file), so the menu can mark it. Skipped with no getDefault.
|
|
89
|
+
const isModified = (f) => {
|
|
90
|
+
if (!getDefault)
|
|
91
|
+
return false;
|
|
92
|
+
const cur = get(f.path);
|
|
93
|
+
const def = getDefault(f.path);
|
|
94
|
+
try {
|
|
95
|
+
return JSON.stringify(cur) !== JSON.stringify(def);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return String(cur) !== String(def);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
62
101
|
// Menu rendering: lines go top to bottom, and we return the cursor to the
|
|
63
102
|
// start of the block via relative moves so we don't spawn blank lines.
|
|
64
103
|
let lastRows = 0;
|
|
65
104
|
const render = () => {
|
|
66
105
|
const lines = [];
|
|
67
106
|
lines.push(theme.bold(t('cfg.menu.title')));
|
|
107
|
+
const vis = visible();
|
|
108
|
+
if (cursor >= vis.length)
|
|
109
|
+
cursor = Math.max(0, vis.length - 1);
|
|
68
110
|
let lastGroup = '';
|
|
69
|
-
for (let i = 0; i <
|
|
70
|
-
const f =
|
|
111
|
+
for (let i = 0; i < vis.length; i++) {
|
|
112
|
+
const f = vis[i];
|
|
71
113
|
const g = groupLabel(f, t);
|
|
72
114
|
if (g !== lastGroup) {
|
|
73
|
-
lines.push(
|
|
115
|
+
lines.push(g);
|
|
74
116
|
lastGroup = g;
|
|
75
117
|
}
|
|
76
118
|
const selected = i === cursor;
|
|
@@ -80,28 +122,50 @@ export function runConfigMenu(opts) {
|
|
|
80
122
|
: t(f.labelKey || f.path);
|
|
81
123
|
const val = displayValue(f, get(f.path), t);
|
|
82
124
|
const valText = selected ? theme.assistant(val) : theme.dim(val);
|
|
125
|
+
const mod = isModified(f) ? theme.warn('*') : '';
|
|
83
126
|
const pathText = selected ? theme.dim(' ' + f.path) : '';
|
|
84
|
-
lines.push(marker + label + theme.dim(' = ') + valText + pathText);
|
|
127
|
+
lines.push(marker + label + theme.dim(' = ') + valText + mod + pathText);
|
|
128
|
+
}
|
|
129
|
+
if (!vis.length) {
|
|
130
|
+
lines.push(theme.dim(' ' + t('cfg.menu.no_match')));
|
|
85
131
|
}
|
|
86
132
|
if (editing) {
|
|
87
133
|
lines.push('');
|
|
88
134
|
lines.push(theme.user(t('cfg.menu.edit_hint')));
|
|
89
135
|
lines.push(theme.prompt('\u276f ') + editBuf);
|
|
90
136
|
}
|
|
137
|
+
else if (filterMode) {
|
|
138
|
+
lines.push('');
|
|
139
|
+
lines.push(theme.user(t('cfg.menu.filter_hint')));
|
|
140
|
+
lines.push(theme.prompt('\u276f ') + filterQuery);
|
|
141
|
+
}
|
|
91
142
|
else if (message) {
|
|
92
143
|
lines.push('');
|
|
93
144
|
lines.push(theme.assistant(message));
|
|
94
145
|
}
|
|
95
146
|
else {
|
|
96
147
|
lines.push('');
|
|
97
|
-
|
|
148
|
+
if (filterQuery) {
|
|
149
|
+
lines.push(theme.dim(t('cfg.menu.filter_active', { q: filterQuery }) +
|
|
150
|
+
' ' +
|
|
151
|
+
t('cfg.menu.hint')));
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
lines.push(' ' + theme.dim(t('cfg.menu.hint')));
|
|
155
|
+
lines.push(' ' + theme.dim(t('cfg.menu.filter_hint')));
|
|
156
|
+
}
|
|
98
157
|
}
|
|
99
158
|
// Erase the previous block and print the new one. We count VISUAL lines
|
|
100
159
|
// (accounting for wrapping by terminal width), otherwise on narrow
|
|
101
160
|
// terminals you get "garbage" from unerased tails.
|
|
161
|
+
// Reserve the last column. A line whose visible width reaches exactly the
|
|
162
|
+
// terminal width hits the autowrap edge: depending on the terminal it
|
|
163
|
+
// either consumes two rows for the following `\n` or keeps a pending wrap,
|
|
164
|
+
// and in both cases a full-width divisor UNDERCOUNTS the block height — the
|
|
165
|
+
// erase then misses the tail and the config lines DUPLICATE on screen (the
|
|
166
|
+
// reported bug). Overcounting by one is a harmless blank row; undercounting
|
|
167
|
+
// is not, so we bias to the safe side here.
|
|
102
168
|
const cols = output.columns || 80;
|
|
103
|
-
// Reserve the last column like the editor does: a row that fills the
|
|
104
|
-
// terminal exactly can autowrap, which desyncs the block height.
|
|
105
169
|
const usable = Math.max(1, cols - 1);
|
|
106
170
|
const rowsOf = (s) => {
|
|
107
171
|
return Math.max(1, Math.ceil(visCols(s) / usable));
|
|
@@ -129,12 +193,12 @@ export function runConfigMenu(opts) {
|
|
|
129
193
|
lastRows = 0;
|
|
130
194
|
}
|
|
131
195
|
};
|
|
132
|
-
const cleanup = (
|
|
196
|
+
const cleanup = (cleanOnExit = false) => {
|
|
133
197
|
if (exited)
|
|
134
198
|
return;
|
|
135
199
|
exited = true;
|
|
136
200
|
input.removeListener('data', onData);
|
|
137
|
-
if (
|
|
201
|
+
if (cleanOnExit)
|
|
138
202
|
clearBlock();
|
|
139
203
|
if (input.setRawMode)
|
|
140
204
|
input.setRawMode(wasRaw || false);
|
|
@@ -143,10 +207,12 @@ export function runConfigMenu(opts) {
|
|
|
143
207
|
resolveDone();
|
|
144
208
|
};
|
|
145
209
|
const commitEdit = () => {
|
|
146
|
-
const f =
|
|
210
|
+
const f = currentField();
|
|
147
211
|
const raw = editBuf.trim();
|
|
148
212
|
editing = false;
|
|
149
213
|
editBuf = '';
|
|
214
|
+
if (!f)
|
|
215
|
+
return;
|
|
150
216
|
if (!raw) {
|
|
151
217
|
message = null;
|
|
152
218
|
return;
|
|
@@ -163,7 +229,9 @@ export function runConfigMenu(opts) {
|
|
|
163
229
|
}
|
|
164
230
|
};
|
|
165
231
|
const toggle = () => {
|
|
166
|
-
const f =
|
|
232
|
+
const f = currentField();
|
|
233
|
+
if (!f)
|
|
234
|
+
return;
|
|
167
235
|
if (f.type === 'boolean') {
|
|
168
236
|
const cur = get(f.path);
|
|
169
237
|
set(f, cur === true ? 'false' : 'true');
|
|
@@ -213,20 +281,49 @@ export function runConfigMenu(opts) {
|
|
|
213
281
|
render();
|
|
214
282
|
return;
|
|
215
283
|
}
|
|
284
|
+
// Filter mode (/): printable keys build the query, Esc/Enter leave it
|
|
285
|
+
// (keeping the query active), Ctrl+C exits the whole menu.
|
|
286
|
+
if (filterMode) {
|
|
287
|
+
for (const ch of s) {
|
|
288
|
+
if (ch === '\r' || ch === '\n' || ch === ESC) {
|
|
289
|
+
filterMode = false;
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
if (ch === '\x03') {
|
|
293
|
+
cleanup(true);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (ch === '\x7f' || ch === '\b') {
|
|
297
|
+
filterQuery = filterQuery.slice(0, -1);
|
|
298
|
+
}
|
|
299
|
+
else if (ch >= ' ') {
|
|
300
|
+
filterQuery += ch;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
cursor = 0;
|
|
304
|
+
render();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
216
307
|
// The terminal may send several keys in one packet (fast typing,
|
|
217
308
|
// auto-repeat, automation). We parse the buffer into tokens: first
|
|
218
309
|
// escape sequences (arrows), then single characters.
|
|
219
310
|
let i = 0;
|
|
220
311
|
while (i < s.length) {
|
|
221
312
|
if (s.startsWith('\x1b[A', i)) {
|
|
222
|
-
|
|
313
|
+
const n = visible().length;
|
|
314
|
+
if (n > 0)
|
|
315
|
+
cursor = (cursor - 1 + n) % n;
|
|
223
316
|
message = null;
|
|
317
|
+
pendingReset = null;
|
|
224
318
|
i += 3;
|
|
225
319
|
continue;
|
|
226
320
|
}
|
|
227
321
|
if (s.startsWith('\x1b[B', i)) {
|
|
228
|
-
|
|
322
|
+
const n = visible().length;
|
|
323
|
+
if (n > 0)
|
|
324
|
+
cursor = (cursor + 1) % n;
|
|
229
325
|
message = null;
|
|
326
|
+
pendingReset = null;
|
|
230
327
|
i += 3;
|
|
231
328
|
continue;
|
|
232
329
|
}
|
|
@@ -237,21 +334,51 @@ export function runConfigMenu(opts) {
|
|
|
237
334
|
cleanup(true);
|
|
238
335
|
return;
|
|
239
336
|
}
|
|
240
|
-
if (ch === '
|
|
241
|
-
|
|
337
|
+
if (ch === '/') {
|
|
338
|
+
filterMode = true;
|
|
339
|
+
message = null;
|
|
340
|
+
// The rest of THIS packet belongs to the filter query (typing " /debug"
|
|
341
|
+
// arrives as one chunk, and without this the trailing characters fell
|
|
342
|
+
// through to the command keys — 'd' armed a reset instead of filtering).
|
|
343
|
+
for (const rest of s.slice(i + 1)) {
|
|
344
|
+
if (rest === '\x7f' || rest === '\b') {
|
|
345
|
+
filterQuery = filterQuery.slice(0, -1);
|
|
346
|
+
}
|
|
347
|
+
else if (rest >= ' ') {
|
|
348
|
+
filterQuery += rest;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
cursor = 0;
|
|
352
|
+
i = s.length;
|
|
353
|
+
}
|
|
354
|
+
else if (ch === 'k') {
|
|
355
|
+
const n = visible().length;
|
|
356
|
+
if (n > 0)
|
|
357
|
+
cursor = (cursor - 1 + n) % n;
|
|
242
358
|
message = null;
|
|
243
359
|
}
|
|
244
360
|
else if (ch === 'j') {
|
|
245
|
-
|
|
361
|
+
const n = visible().length;
|
|
362
|
+
if (n > 0)
|
|
363
|
+
cursor = (cursor + 1) % n;
|
|
246
364
|
message = null;
|
|
247
365
|
}
|
|
248
366
|
else if (ch === '\r' || ch === '\n' || ch === ' ') {
|
|
249
367
|
toggle();
|
|
250
368
|
}
|
|
251
369
|
else if (ch === 'd' || ch === 'D') {
|
|
252
|
-
const f =
|
|
253
|
-
|
|
254
|
-
|
|
370
|
+
const f = currentField();
|
|
371
|
+
if (f) {
|
|
372
|
+
if (pendingReset === f.path) {
|
|
373
|
+
reset(f);
|
|
374
|
+
message = t('cfg.reset', { v: f.path });
|
|
375
|
+
pendingReset = null;
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
pendingReset = f.path;
|
|
379
|
+
message = t('cfg.menu.reset_confirm', { v: f.path });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
255
382
|
}
|
|
256
383
|
i++;
|
|
257
384
|
}
|
package/dist/i18n.js
CHANGED
|
@@ -39,6 +39,10 @@ const CATALOG = {
|
|
|
39
39
|
'common.unknown': { ru: 'неизвестно', en: 'unknown' },
|
|
40
40
|
// ---------- help ----------
|
|
41
41
|
'help.options': { ru: 'Опции CLI:', en: 'CLI options:' },
|
|
42
|
+
'help.color_note': {
|
|
43
|
+
ru: 'Цвет отключается переменной окружения NO_COLOR=1.',
|
|
44
|
+
en: 'Disable color with the NO_COLOR=1 environment variable.',
|
|
45
|
+
},
|
|
42
46
|
'help.opt.dir': {
|
|
43
47
|
ru: 'рабочая директория агента',
|
|
44
48
|
en: 'agent working directory',
|
|
@@ -95,6 +99,10 @@ const CATALOG = {
|
|
|
95
99
|
ru: '↑ / ↓ история введённых сообщений',
|
|
96
100
|
en: '↑ / ↓ input history',
|
|
97
101
|
},
|
|
102
|
+
'help.key.search': {
|
|
103
|
+
ru: 'Ctrl+R поиск по истории (обратный)',
|
|
104
|
+
en: 'Ctrl+R reverse-search the history',
|
|
105
|
+
},
|
|
98
106
|
'help.key.words': {
|
|
99
107
|
ru: 'Ctrl+← / Ctrl+→ перемещение по словам',
|
|
100
108
|
en: 'Ctrl+← / Ctrl+→ move by words',
|
|
@@ -140,6 +148,10 @@ const CATALOG = {
|
|
|
140
148
|
ru: '/resume <n> открыть чат №n из /chats',
|
|
141
149
|
en: '/resume <n> open chat #n from /chats',
|
|
142
150
|
},
|
|
151
|
+
'help.cmd.last': {
|
|
152
|
+
ru: '/last открыть последний чат этого каталога',
|
|
153
|
+
en: '/last open the last chat of this directory',
|
|
154
|
+
},
|
|
143
155
|
'help.cmd.chat': {
|
|
144
156
|
ru: '/chat показать текущий chat id',
|
|
145
157
|
en: '/chat show current chat id',
|
|
@@ -464,6 +476,15 @@ const CATALOG = {
|
|
|
464
476
|
'state.generating': { ru: '▶ генерация', en: '▶ generating' },
|
|
465
477
|
'editor.more': { ru: '…ещё {n}', en: '…{n} more' },
|
|
466
478
|
'editor.answer': { ru: '● Ответ', en: '● Answer' },
|
|
479
|
+
// Reverse search (Ctrl+R) status line. The query may be empty.
|
|
480
|
+
'editor.search_prompt': {
|
|
481
|
+
ru: '(обратный поиск) `{q}` — Ctrl+R дальше, Enter принять, Esc отмена',
|
|
482
|
+
en: '(reverse-i-search) `{q}` — Ctrl+R next, Enter accept, Esc cancel',
|
|
483
|
+
},
|
|
484
|
+
'editor.search_fail': {
|
|
485
|
+
ru: '(обратный поиск) `{q}` — не найдено, Esc отмена',
|
|
486
|
+
en: '(reverse-i-search) `{q}` — not found, Esc cancel',
|
|
487
|
+
},
|
|
467
488
|
// ---------- status ----------
|
|
468
489
|
'status.workdir': {
|
|
469
490
|
ru: 'Рабочая директория: {v}',
|
|
@@ -539,10 +560,15 @@ const CATALOG = {
|
|
|
539
560
|
en: 'Opening chat {id}...',
|
|
540
561
|
},
|
|
541
562
|
'msg.chat_opened': { ru: 'Чат открыт.', en: 'Chat opened.' },
|
|
563
|
+
'msg.version': { ru: 'Версия: {v}', en: 'Version: {v}' },
|
|
542
564
|
'msg.interactive': {
|
|
543
565
|
ru: 'Интерактивный режим. Введите задачу. Команды — /help. Выход — /exit.',
|
|
544
566
|
en: 'Interactive mode. Enter a task. Commands — /help. Exit — /exit.',
|
|
545
567
|
},
|
|
568
|
+
'msg.first_hint': {
|
|
569
|
+
ru: 'Подсказка: «/» — список команд, Ctrl+R — поиск по истории, картинку можно вставить через Ctrl+Shift+V.',
|
|
570
|
+
en: 'Hint: "/" lists commands, Ctrl+R searches history, paste an image with Ctrl+Shift+V.',
|
|
571
|
+
},
|
|
546
572
|
'msg.queue_hint': {
|
|
547
573
|
ru: 'Пока агент работает, можно печатать следующее сообщение — оно уйдёт в очередь (Enter — отправить, Esc — прервать).',
|
|
548
574
|
en: 'While the agent works you can type the next message — it goes to the queue (Enter — send, Esc — abort).',
|
|
@@ -601,6 +627,99 @@ const CATALOG = {
|
|
|
601
627
|
'dur.h': { ru: 'ч', en: 'h' },
|
|
602
628
|
'dur.m': { ru: 'м', en: 'm' },
|
|
603
629
|
'dur.s': { ru: 'с', en: 's' },
|
|
630
|
+
// ---------- /diff ----------
|
|
631
|
+
'diff.no_changes': { ru: '(нет изменений)', en: '(no changes)' },
|
|
632
|
+
'diff.more_lines': {
|
|
633
|
+
ru: '... [{n} строк ещё, полный вывод — Bash git diff]',
|
|
634
|
+
en: '... [{n} more lines, use Bash git diff for the full output]',
|
|
635
|
+
},
|
|
636
|
+
// ---------- /cost ----------
|
|
637
|
+
'cost.title': { ru: 'Статистика сессии:', en: 'Session stats:' },
|
|
638
|
+
'cost.context_known': {
|
|
639
|
+
ru: ' контекст: ~{n} токенов (DeepSeek accumulated_token_usage)',
|
|
640
|
+
en: ' context: ~{n} tokens (DeepSeek accumulated_token_usage)',
|
|
641
|
+
},
|
|
642
|
+
'cost.context_unknown': {
|
|
643
|
+
ru: ' контекст: неизвестно (DeepSeek сообщит после первого ответа в чате)',
|
|
644
|
+
en: ' context: unknown (DeepSeek reports it after the first answer in a chat)',
|
|
645
|
+
},
|
|
646
|
+
'cost.tasks': { ru: ' задач: {n}', en: ' tasks: {n}' },
|
|
647
|
+
'cost.tool_calls': {
|
|
648
|
+
ru: ' вызовов инструментов: {n}',
|
|
649
|
+
en: ' tool calls: {n}',
|
|
650
|
+
},
|
|
651
|
+
'cost.auto_compacts': { ru: ' авто-сжатий: {n}', en: ' auto-compacts: {n}' },
|
|
652
|
+
'cost.by_tool': { ru: ' по инструментам:', en: ' by tool:' },
|
|
653
|
+
'cost.duration': { ru: ' длительность: {dur}', en: ' duration: {dur}' },
|
|
654
|
+
'cost.started': { ru: ' начало: {v}', en: ' started: {v}' },
|
|
655
|
+
'cost.transcript': { ru: ' транскрипт: {v}', en: ' transcript: {v}' },
|
|
656
|
+
'cost.off': { ru: '(выкл)', en: '(off)' },
|
|
657
|
+
// ---------- /doctor ----------
|
|
658
|
+
'doctor.title': { ru: 'Диагностика:', en: 'Doctor:' },
|
|
659
|
+
'doctor.repo': { ru: 'репозиторий ({v})', en: 'repo ({v})' },
|
|
660
|
+
'doctor.detached': { ru: 'detached', en: 'detached' },
|
|
661
|
+
'doctor.not_repo': { ru: 'не репозиторий', en: 'not a repository' },
|
|
662
|
+
'doctor.loaded': { ru: 'загружен', en: 'loaded' },
|
|
663
|
+
'doctor.error': { ru: 'ошибка: {v}', en: 'error: {v}' },
|
|
664
|
+
'doctor.unknown': { ru: 'неизвестно', en: 'unknown' },
|
|
665
|
+
'doctor.bundled': { ru: 'встроенный chromium', en: 'bundled chromium' },
|
|
666
|
+
'doctor.auth_saved': {
|
|
667
|
+
ru: 'сессия сохранена (авто-вход готов)',
|
|
668
|
+
en: 'session saved (auto re-login ready)',
|
|
669
|
+
},
|
|
670
|
+
'doctor.auth_none': { ru: 'нет сохранённой сессии', en: 'no saved session' },
|
|
671
|
+
'doctor.clipboard_none': {
|
|
672
|
+
ru: 'инструмент не найден (вставка картинок отключена)',
|
|
673
|
+
en: 'no tool found (image paste disabled)',
|
|
674
|
+
},
|
|
675
|
+
'doctor.mcp': {
|
|
676
|
+
ru: '{servers} сервер(ов), {tools} инструмент(ов)',
|
|
677
|
+
en: '{servers} server(s), {tools} tool(s)',
|
|
678
|
+
},
|
|
679
|
+
'doctor.tokens': { ru: '{v} токенов', en: '{v} tokens' },
|
|
680
|
+
'doctor.origin_ok': { ru: 'origin настроен', en: 'origin configured' },
|
|
681
|
+
'doctor.origin_none': { ru: 'нет origin', en: 'no origin' },
|
|
682
|
+
'doctor.send_pause': {
|
|
683
|
+
ru: '{n}с между отправками агента',
|
|
684
|
+
en: '{n}s between agent sends',
|
|
685
|
+
},
|
|
686
|
+
'doctor.ssh': { ru: 'SSH-подключение', en: 'SSH remote' },
|
|
687
|
+
'doctor.local': { ru: 'локальный терминал', en: 'local terminal' },
|
|
688
|
+
'doctor.ua_cached': {
|
|
689
|
+
ru: 'UA закэширован (без перезапуска)',
|
|
690
|
+
en: 'UA cached (no relaunch)',
|
|
691
|
+
},
|
|
692
|
+
'doctor.ua_not_cached': {
|
|
693
|
+
ru: 'UA ещё не закэширован (первый запуск перезапустится один раз)',
|
|
694
|
+
en: 'no cached UA yet (first start relaunches once)',
|
|
695
|
+
},
|
|
696
|
+
// ---------- /permissions ----------
|
|
697
|
+
'perm.title': {
|
|
698
|
+
ru: 'Права инструментов (настройки подтверждений):',
|
|
699
|
+
en: 'Tool permissions (confirmation settings):',
|
|
700
|
+
},
|
|
701
|
+
'perm.write': { ru: ' Write: {v}', en: ' Write: {v}' },
|
|
702
|
+
'perm.edit': { ru: ' Edit: {v}', en: ' Edit: {v}' },
|
|
703
|
+
'perm.bash': { ru: ' Bash: {v}', en: ' Bash: {v}' },
|
|
704
|
+
'perm.ask': { ru: 'спрашивать', en: 'ask' },
|
|
705
|
+
'perm.allow': { ru: 'разрешить', en: 'allow' },
|
|
706
|
+
'perm.always': {
|
|
707
|
+
ru: ' Всегда подтверждать (regex):',
|
|
708
|
+
en: ' Always confirm (regex):',
|
|
709
|
+
},
|
|
710
|
+
'perm.change_hint': {
|
|
711
|
+
ru: 'Изменить: /config set confirmation.write false (и .edit / .bash)',
|
|
712
|
+
en: 'Change with: /config set confirmation.write false (and .edit / .bash)',
|
|
713
|
+
},
|
|
714
|
+
// ---------- /add-dir ----------
|
|
715
|
+
'adddir.usage': {
|
|
716
|
+
ru: 'Использование: /add-dir <path>',
|
|
717
|
+
en: 'Usage: /add-dir <path>',
|
|
718
|
+
},
|
|
719
|
+
'adddir.same': {
|
|
720
|
+
ru: 'Это уже рабочая директория.',
|
|
721
|
+
en: 'This is already the working directory.',
|
|
722
|
+
},
|
|
604
723
|
'msg.from_queue': { ru: '▶ Из очереди: ', en: '▶ From queue: ' },
|
|
605
724
|
'msg.from_queue_batch': {
|
|
606
725
|
ru: '▶ Из очереди ({n} сообщ.): ',
|
|
@@ -1273,6 +1392,19 @@ const CATALOG = {
|
|
|
1273
1392
|
en: '✔ Saved: {v} = {value}',
|
|
1274
1393
|
},
|
|
1275
1394
|
'cfg.menu.default': { ru: 'по умолчанию', en: 'default' },
|
|
1395
|
+
'cfg.menu.filter_hint': {
|
|
1396
|
+
ru: '/ — поиск по настройкам, d — сбросить (дважды), * — изменено от дефолта',
|
|
1397
|
+
en: '/ search settings, d reset (press twice), * changed from default',
|
|
1398
|
+
},
|
|
1399
|
+
'cfg.menu.filter_active': {
|
|
1400
|
+
ru: 'Фильтр: «{q}» (Esc/Enter — очистить)',
|
|
1401
|
+
en: 'Filter: "{q}" (Esc/Enter to clear)',
|
|
1402
|
+
},
|
|
1403
|
+
'cfg.menu.no_match': { ru: '(нет совпадений)', en: '(no matches)' },
|
|
1404
|
+
'cfg.menu.reset_confirm': {
|
|
1405
|
+
ru: 'Нажми d ещё раз, чтобы сбросить {v}',
|
|
1406
|
+
en: 'Press d again to reset {v}',
|
|
1407
|
+
},
|
|
1276
1408
|
'cfg.menu.notty': {
|
|
1277
1409
|
ru: 'Меню доступно только в интерактивном терминале. Используй /config list|get|set.',
|
|
1278
1410
|
en: 'The menu needs an interactive terminal. Use /config list|get|set.',
|
package/dist/index.js
CHANGED
|
@@ -292,9 +292,12 @@ ${theme.bold(t('help.options'))}
|
|
|
292
292
|
--version, -v ${t('help.opt.version')}
|
|
293
293
|
--help, -h ${t('help.opt.help')}
|
|
294
294
|
|
|
295
|
+
${t('help.color_note')}
|
|
296
|
+
|
|
295
297
|
${theme.bold(t('help.while_working'))}
|
|
296
298
|
${t('help.key.queue')}
|
|
297
299
|
${t('help.key.history')}
|
|
300
|
+
${t('help.key.search')}
|
|
298
301
|
${t('help.key.words')}
|
|
299
302
|
${t('help.key.slash')}
|
|
300
303
|
${t('help.key.newline')}
|
|
@@ -308,6 +311,7 @@ ${theme.bold(t('help.commands'))}
|
|
|
308
311
|
${t('help.cmd.resume_id')}
|
|
309
312
|
${t('help.cmd.chats')}
|
|
310
313
|
${t('help.cmd.resume')}
|
|
314
|
+
${t('help.cmd.last')}
|
|
311
315
|
${t('help.cmd.chat')}
|
|
312
316
|
${t('help.cmd.cd')}
|
|
313
317
|
${t('help.cmd.pwd')}
|
|
@@ -316,20 +320,20 @@ ${theme.bold(t('help.commands'))}
|
|
|
316
320
|
${t('help.cmd.undo')}
|
|
317
321
|
${t('help.cmd.undo_list')}
|
|
318
322
|
${t('help.cmd.transcript')}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
323
|
+
${t('help.cmd.diff')}
|
|
324
|
+
${t('help.cmd.cost')}
|
|
325
|
+
${t('help.cmd.export')}
|
|
326
|
+
${t('help.cmd.doctor')}
|
|
327
|
+
${t('help.cmd.permissions')}
|
|
328
|
+
${t('help.cmd.add_dir')}
|
|
329
|
+
${t('help.cmd.review')}
|
|
330
|
+
${t('help.cmd.goal')}
|
|
331
|
+
${t('help.cmd.loop')}
|
|
332
|
+
${t('help.cmd.cron')}
|
|
333
|
+
${t('help.cmd.jobs')}
|
|
334
|
+
${t('help.cmd.thinking')}
|
|
335
|
+
${t('help.cmd.web')}
|
|
336
|
+
${t('help.cmd.compact')}
|
|
333
337
|
${t('help.cmd.queue')}
|
|
334
338
|
${t('help.cmd.tasks')}
|
|
335
339
|
${t('help.cmd.config')}
|
|
@@ -372,6 +376,7 @@ const SLASH_COMMANDS = [
|
|
|
372
376
|
{ name: '/chats', key: 'help.cmd.chats' },
|
|
373
377
|
{ name: '/resume', key: 'help.cmd.resume' },
|
|
374
378
|
{ name: '/resume-id', key: 'help.cmd.resume_id' },
|
|
379
|
+
{ name: '/last', key: 'help.cmd.last' },
|
|
375
380
|
{ name: '/chat', key: 'help.cmd.chat' },
|
|
376
381
|
{ name: '/cd', key: 'help.cmd.cd' },
|
|
377
382
|
{ name: '/pwd', key: 'help.cmd.pwd' },
|
|
@@ -1190,9 +1195,14 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
|
|
|
1190
1195
|
}
|
|
1191
1196
|
// ---------- main ----------
|
|
1192
1197
|
async function main() {
|
|
1198
|
+
// Read the package version once: --version prints it and the interactive
|
|
1199
|
+
// banner shows it (a version in the first lines is what a bug report needs).
|
|
1200
|
+
const pkgVersion = await fs
|
|
1201
|
+
.readFile(path.join(__dirname, '..', 'package.json'), 'utf-8')
|
|
1202
|
+
.then((raw) => JSON.parse(raw).version)
|
|
1203
|
+
.catch(() => '');
|
|
1193
1204
|
if (hasFlag('--version') || hasFlag('-v')) {
|
|
1194
|
-
|
|
1195
|
-
console.log(pkg.version);
|
|
1205
|
+
console.log(pkgVersion || 'unknown');
|
|
1196
1206
|
return;
|
|
1197
1207
|
}
|
|
1198
1208
|
if (hasFlag('--help') || hasFlag('-h')) {
|
|
@@ -1215,6 +1225,7 @@ async function main() {
|
|
|
1215
1225
|
process.exit(1);
|
|
1216
1226
|
}
|
|
1217
1227
|
console.log(theme.system(t('msg.working_dir', { v: currentWorkdir })));
|
|
1228
|
+
console.log(theme.system(t('msg.version', { v: pkgVersion || '?' })));
|
|
1218
1229
|
const transcript = new Transcript({
|
|
1219
1230
|
dir: config.transcript.dir,
|
|
1220
1231
|
enabled: config.transcript.enabled,
|
|
@@ -1327,6 +1338,7 @@ async function main() {
|
|
|
1327
1338
|
}
|
|
1328
1339
|
console.log(theme.system(t('msg.interactive')));
|
|
1329
1340
|
console.log(theme.system(t('msg.queue_hint')));
|
|
1341
|
+
console.log(theme.dim(t('msg.first_hint')));
|
|
1330
1342
|
let freshChatNext = true;
|
|
1331
1343
|
let sendSystemPromptNext = true;
|
|
1332
1344
|
// Resuming an existing chat already has the system-prompt at its start, so
|
|
@@ -1946,6 +1958,7 @@ async function main() {
|
|
|
1946
1958
|
fields: CONFIG_SCHEMA,
|
|
1947
1959
|
t,
|
|
1948
1960
|
get: (path) => getByPath(config, path),
|
|
1961
|
+
getDefault: (path) => getByPath(DEFAULTS, path),
|
|
1949
1962
|
set: (field, raw) => configSetRaw(field, raw),
|
|
1950
1963
|
reset: (field) => configResetField(field),
|
|
1951
1964
|
});
|
|
@@ -2407,6 +2420,39 @@ async function main() {
|
|
|
2407
2420
|
}
|
|
2408
2421
|
continue;
|
|
2409
2422
|
}
|
|
2423
|
+
if (lower === '/last') {
|
|
2424
|
+
// Quick resume of the last chat for THIS working directory — otherwise
|
|
2425
|
+
// finding it meant /chats (a network fetch) or copying the id.
|
|
2426
|
+
const last = loadLastSession(currentWorkdir);
|
|
2427
|
+
if (!last || !last.id) {
|
|
2428
|
+
console.error(theme.error(t('sessions.none')));
|
|
2429
|
+
continue;
|
|
2430
|
+
}
|
|
2431
|
+
if (editor)
|
|
2432
|
+
editor.lock(t('msg.input_locked'));
|
|
2433
|
+
try {
|
|
2434
|
+
if (editor)
|
|
2435
|
+
editor.clearScreen();
|
|
2436
|
+
console.log(theme.system(t('msg.opening_chat', { id: last.id })));
|
|
2437
|
+
await browser.openChat(last.id);
|
|
2438
|
+
currentChatId = last.id;
|
|
2439
|
+
freshChatNext = false;
|
|
2440
|
+
sendSystemPromptNext = promptOnResume();
|
|
2441
|
+
saveLastChat(last.id, currentWorkdir, last.title);
|
|
2442
|
+
transcript.log('resume_chat', { id: last.id, title: last.title });
|
|
2443
|
+
console.log(theme.assistant(t('msg.chat_opened')));
|
|
2444
|
+
loadSessionTodos(last.id);
|
|
2445
|
+
await printRestoredHistory(browser, editor, last.id);
|
|
2446
|
+
}
|
|
2447
|
+
catch (e) {
|
|
2448
|
+
console.error(theme.error(t('msg.open_chat_error', { v: '' })), e.message);
|
|
2449
|
+
}
|
|
2450
|
+
finally {
|
|
2451
|
+
if (editor)
|
|
2452
|
+
editor.unlock();
|
|
2453
|
+
}
|
|
2454
|
+
continue;
|
|
2455
|
+
}
|
|
2410
2456
|
if (lower.startsWith('/resume-id ')) {
|
|
2411
2457
|
const id = trimmed.slice('/resume-id'.length).trim();
|
|
2412
2458
|
if (!id) {
|
|
@@ -2818,7 +2864,7 @@ async function main() {
|
|
|
2818
2864
|
continue;
|
|
2819
2865
|
}
|
|
2820
2866
|
const out = await runGit(diffGitArgs(staged), currentWorkdir, 20_000);
|
|
2821
|
-
console.log(theme.system(formatDiff(out, { maxLines: 400 })));
|
|
2867
|
+
console.log(theme.system(formatDiff(out, { maxLines: 400 }, t)));
|
|
2822
2868
|
continue;
|
|
2823
2869
|
}
|
|
2824
2870
|
if (lower === '/cost' || lower === '/usage') {
|
|
@@ -2832,7 +2878,7 @@ async function main() {
|
|
|
2832
2878
|
// best-effort
|
|
2833
2879
|
}
|
|
2834
2880
|
}
|
|
2835
|
-
console.log(theme.system(renderCost(stats, transcript.file, browser.getLastTokenUsage())));
|
|
2881
|
+
console.log(theme.system(renderCost(stats, transcript.file, browser.getLastTokenUsage(), t)));
|
|
2836
2882
|
continue;
|
|
2837
2883
|
}
|
|
2838
2884
|
if (lower === '/export' || lower.startsWith('/export ')) {
|
|
@@ -2907,7 +2953,7 @@ async function main() {
|
|
|
2907
2953
|
sshRemote: !!(process.env.SSH_CONNECTION || process.env.SSH_TTY),
|
|
2908
2954
|
headless: !!headless,
|
|
2909
2955
|
headlessUaCached: await headlessUACacheReady().catch(() => false),
|
|
2910
|
-
})));
|
|
2956
|
+
}, t)));
|
|
2911
2957
|
continue;
|
|
2912
2958
|
}
|
|
2913
2959
|
if (lower === '/permissions' || lower === '/allowed-tools') {
|
|
@@ -2916,12 +2962,12 @@ async function main() {
|
|
|
2916
2962
|
edit: config.confirmation.edit,
|
|
2917
2963
|
bash: config.confirmation.bash,
|
|
2918
2964
|
alwaysConfirm: config.confirmation.alwaysConfirm,
|
|
2919
|
-
})));
|
|
2965
|
+
}, t)));
|
|
2920
2966
|
continue;
|
|
2921
2967
|
}
|
|
2922
2968
|
if (lower === '/add-dir' || lower.startsWith('/add-dir ')) {
|
|
2923
2969
|
const arg = trimmed.slice('/add-dir'.length).trim();
|
|
2924
|
-
const res = resolveExtraDir(arg, currentWorkdir);
|
|
2970
|
+
const res = resolveExtraDir(arg, currentWorkdir, t);
|
|
2925
2971
|
if ('error' in res) {
|
|
2926
2972
|
console.error(theme.warn(res.error));
|
|
2927
2973
|
continue;
|
package/dist/input.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { theme } from './theme.js';
|
|
1
|
+
import { theme, divider } from './theme.js';
|
|
2
2
|
import { renderMarkdown } from './markdown.js';
|
|
3
3
|
import { randomThinkingPhrase, stripEllipsis, DOTS, renderDots, } from './spinner.js';
|
|
4
4
|
import { translate } from './i18n.js';
|
|
@@ -315,10 +315,10 @@ export class LineEditor {
|
|
|
315
315
|
// True while the dot-animation timer is running. Lets sendPause() update
|
|
316
316
|
// only the base text (remaining seconds) without restarting the timer.
|
|
317
317
|
_animating;
|
|
318
|
-
// Whether the animated status shows the trailing "Esc — стоп" hint. Off
|
|
319
|
-
// while a tool runs (its process cannot be cancelled).
|
|
320
|
-
_showHint;
|
|
321
318
|
_dotPhase;
|
|
319
|
+
// When the current animated phase started, so the status can show its
|
|
320
|
+
// elapsed time. 0 when nothing is animating.
|
|
321
|
+
_animStart;
|
|
322
322
|
_thinkBase;
|
|
323
323
|
// Explicit lifecycle state of the send ('generating' | 'paused' |
|
|
324
324
|
// 'settled' | ''). The thinking spinner already shows ACTIVITY, but it does
|
|
@@ -351,6 +351,11 @@ export class LineEditor {
|
|
|
351
351
|
history;
|
|
352
352
|
_histIndex;
|
|
353
353
|
_histDraft;
|
|
354
|
+
// Incremental reverse-search over the history (Ctrl+R), bash-style.
|
|
355
|
+
_searchMode;
|
|
356
|
+
_searchQuery;
|
|
357
|
+
_searchIndex;
|
|
358
|
+
_searchSavedBuf;
|
|
354
359
|
// Slash-command hints (shown when you type «/»).
|
|
355
360
|
slashCommands;
|
|
356
361
|
_suggestCount;
|
|
@@ -422,8 +427,8 @@ export class LineEditor {
|
|
|
422
427
|
this._inPaste = false;
|
|
423
428
|
this._dotTimer = null;
|
|
424
429
|
this._animating = false;
|
|
425
|
-
this._showHint = true;
|
|
426
430
|
this._dotPhase = 0;
|
|
431
|
+
this._animStart = 0;
|
|
427
432
|
this._thinkBase = '';
|
|
428
433
|
this._sendState = '';
|
|
429
434
|
this._lastStatusBlock = '';
|
|
@@ -453,6 +458,10 @@ export class LineEditor {
|
|
|
453
458
|
this.history = [];
|
|
454
459
|
this._histIndex = 0;
|
|
455
460
|
this._histDraft = '';
|
|
461
|
+
this._searchMode = false;
|
|
462
|
+
this._searchQuery = '';
|
|
463
|
+
this._searchIndex = -1;
|
|
464
|
+
this._searchSavedBuf = '';
|
|
456
465
|
this.slashCommands = commands;
|
|
457
466
|
this._suggestCount = 0;
|
|
458
467
|
this.pastes = [];
|
|
@@ -508,16 +517,17 @@ export class LineEditor {
|
|
|
508
517
|
return this.contextStatus;
|
|
509
518
|
}
|
|
510
519
|
// The context text colored by fill level: green (ok), yellow (warn),
|
|
511
|
-
// red (high).
|
|
520
|
+
// red (high). A leading marker repeats the level as TEXT, so the signal
|
|
521
|
+
// survives on a terminal without color and for a color-blind operator.
|
|
512
522
|
_contextText() {
|
|
513
523
|
const ctx = this._contextForRender();
|
|
514
524
|
if (!ctx)
|
|
515
525
|
return '';
|
|
516
526
|
const level = tokenStatusLevel(this.contextTokens, this.contextLimit);
|
|
517
527
|
if (level === 'high')
|
|
518
|
-
return theme.error(ctx);
|
|
528
|
+
return theme.error('⛔ ' + ctx);
|
|
519
529
|
if (level === 'warn')
|
|
520
|
-
return theme.warn(ctx);
|
|
530
|
+
return theme.warn('▲ ' + ctx);
|
|
521
531
|
return theme.success(ctx);
|
|
522
532
|
}
|
|
523
533
|
// The queue badge ("⧗N") shown before the context counter when messages are
|
|
@@ -1071,10 +1081,10 @@ export class LineEditor {
|
|
|
1071
1081
|
_dots(n) {
|
|
1072
1082
|
return renderDots(n);
|
|
1073
1083
|
}
|
|
1074
|
-
// Status-line hint ("Esc — stop"), localized.
|
|
1084
|
+
// Status-line hint ("Esc — stop"), localized. Shown for every animated
|
|
1085
|
+
// state: Esc really aborts both a generation and a running tool (the Bash
|
|
1086
|
+
// child is killed via AbortSignal), so the promise is accurate.
|
|
1075
1087
|
_hint() {
|
|
1076
|
-
if (!this._showHint)
|
|
1077
|
-
return '';
|
|
1078
1088
|
return theme.dim(' · ' + translate(this.locale)('spinner.hint'));
|
|
1079
1089
|
}
|
|
1080
1090
|
// The lifecycle phase prefix for the status line, colored by meaning:
|
|
@@ -1110,24 +1120,37 @@ export class LineEditor {
|
|
|
1110
1120
|
// Shared by the thinking spinner and the send-pause indicator, so the pause
|
|
1111
1121
|
// is animated too (previously the pause was a static console line and the
|
|
1112
1122
|
// dots stayed frozen).
|
|
1113
|
-
|
|
1114
|
-
// `showHint` (default true) controls the trailing "· Esc — стоп". While a
|
|
1115
|
-
// TOOL runs we hide it: the tool's child process cannot be killed, so
|
|
1116
|
-
// promising "Esc to stop" would be misleading.
|
|
1117
|
-
_startAnimated(baseText, showHint = true) {
|
|
1123
|
+
_startAnimated(baseText) {
|
|
1118
1124
|
this._thinkBase = theme.brown(stripEllipsis(baseText));
|
|
1119
|
-
this._showHint = showHint;
|
|
1120
1125
|
this._dotPhase = 0;
|
|
1121
|
-
|
|
1126
|
+
// Remember when the animation began so the status can show how long the
|
|
1127
|
+
// current phase has been running ("12s"): a long reasoning turn otherwise
|
|
1128
|
+
// looks identical to a hung one.
|
|
1129
|
+
this._animStart = Date.now();
|
|
1130
|
+
this.setStatus(this._thinkBase + this._dots(0) + this._elapsedLabel() + this._hint());
|
|
1122
1131
|
this._stopDots();
|
|
1123
1132
|
this._dotTimer = setInterval(() => {
|
|
1124
1133
|
this._dotPhase = (this._dotPhase + 1) % DOTS.length;
|
|
1125
|
-
this.setStatus(this._thinkBase +
|
|
1134
|
+
this.setStatus(this._thinkBase +
|
|
1135
|
+
this._dots(this._dotPhase) +
|
|
1136
|
+
this._elapsedLabel() +
|
|
1137
|
+
this._hint());
|
|
1126
1138
|
}, 400);
|
|
1127
1139
|
this._animating = true;
|
|
1128
1140
|
if (this._dotTimer.unref)
|
|
1129
1141
|
this._dotTimer.unref();
|
|
1130
1142
|
}
|
|
1143
|
+
// Elapsed seconds of the current animated phase, formatted for the status
|
|
1144
|
+
// line ("0s", "45s", "2m 05s"). Empty while nothing is animating.
|
|
1145
|
+
_elapsedLabel() {
|
|
1146
|
+
if (!this._animating || !this._animStart)
|
|
1147
|
+
return '';
|
|
1148
|
+
const sec = Math.floor((Date.now() - this._animStart) / 1000);
|
|
1149
|
+
const m = Math.floor(sec / 60);
|
|
1150
|
+
const s = sec % 60;
|
|
1151
|
+
const text = m > 0 ? m + 'm ' + String(s).padStart(2, '0') + 's' : s + 's';
|
|
1152
|
+
return theme.dim(' · ' + text);
|
|
1153
|
+
}
|
|
1131
1154
|
thinking() {
|
|
1132
1155
|
this._startThinking();
|
|
1133
1156
|
}
|
|
@@ -1194,7 +1217,7 @@ export class LineEditor {
|
|
|
1194
1217
|
NL +
|
|
1195
1218
|
rendered +
|
|
1196
1219
|
NL +
|
|
1197
|
-
theme.dim(
|
|
1220
|
+
theme.dim(divider()));
|
|
1198
1221
|
}
|
|
1199
1222
|
// Warning to the operator (e.g. the agent stopped suspiciously).
|
|
1200
1223
|
// Printed above the input line without overwriting it.
|
|
@@ -1492,6 +1515,93 @@ export class LineEditor {
|
|
|
1492
1515
|
}
|
|
1493
1516
|
this.cursor = Array.from(this.buf).length;
|
|
1494
1517
|
}
|
|
1518
|
+
// ---------- reverse-search (Ctrl+R) ----------
|
|
1519
|
+
// Start an incremental reverse search, keeping the current buffer aside so
|
|
1520
|
+
// Esc restores it (bash behavior). An empty history leaves the editor
|
|
1521
|
+
// untouched.
|
|
1522
|
+
_searchStart() {
|
|
1523
|
+
if (!this.history.length)
|
|
1524
|
+
return;
|
|
1525
|
+
this._searchMode = true;
|
|
1526
|
+
this._searchQuery = '';
|
|
1527
|
+
this._searchIndex = this.history.length - 1;
|
|
1528
|
+
this._searchSavedBuf = this.buf;
|
|
1529
|
+
this._searchRefresh(true);
|
|
1530
|
+
this._searchStatus();
|
|
1531
|
+
}
|
|
1532
|
+
// Recompute the match for the current query. `resetToNewest` moves the
|
|
1533
|
+
// cursor back to the newest entry (used when the query changes; a repeated
|
|
1534
|
+
// Ctrl+R keeps scanning older entries from the current index).
|
|
1535
|
+
_searchRefresh(resetToNewest = false) {
|
|
1536
|
+
const q = this._searchQuery;
|
|
1537
|
+
if (resetToNewest)
|
|
1538
|
+
this._searchIndex = this.history.length - 1;
|
|
1539
|
+
for (let i = this._searchIndex; i >= 0; i--) {
|
|
1540
|
+
if (q === '' || this.history[i].toLowerCase().includes(q.toLowerCase())) {
|
|
1541
|
+
this._searchIndex = i;
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
// No match from here: signal it and leave the buffer as-is.
|
|
1546
|
+
this._searchIndex = -1;
|
|
1547
|
+
}
|
|
1548
|
+
// Show the search state in the status line. The matched entry is previewed
|
|
1549
|
+
// in the input line live, so Enter can accept it at once.
|
|
1550
|
+
_searchStatus() {
|
|
1551
|
+
const t = translate(this.locale);
|
|
1552
|
+
const q = this._searchQuery;
|
|
1553
|
+
if (this._searchIndex >= 0) {
|
|
1554
|
+
// Set the matched buffer BEFORE setStatus(): setStatus renders the
|
|
1555
|
+
// block, so the preview must already be in the buffer.
|
|
1556
|
+
this.buf = this.history[this._searchIndex];
|
|
1557
|
+
this.cursor = Array.from(this.buf).length;
|
|
1558
|
+
this.setStatus(t('editor.search_prompt', { q }));
|
|
1559
|
+
}
|
|
1560
|
+
else {
|
|
1561
|
+
this.setStatus(t('editor.search_fail', { q }));
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
// One more Ctrl+R: move to the PREVIOUS (older) match.
|
|
1565
|
+
_searchNext() {
|
|
1566
|
+
if (this._searchIndex > 0) {
|
|
1567
|
+
const prev = this._searchIndex;
|
|
1568
|
+
this._searchIndex = prev - 1;
|
|
1569
|
+
this._searchRefresh(false);
|
|
1570
|
+
// If nothing matched above `prev`, keep the previous match.
|
|
1571
|
+
if (this._searchIndex < 0)
|
|
1572
|
+
this._searchIndex = prev;
|
|
1573
|
+
}
|
|
1574
|
+
this._searchStatus();
|
|
1575
|
+
}
|
|
1576
|
+
// Enter: keep the matched buffer and leave search mode.
|
|
1577
|
+
_searchAccept() {
|
|
1578
|
+
this._searchMode = false;
|
|
1579
|
+
this._searchQuery = '';
|
|
1580
|
+
this.setStatus('');
|
|
1581
|
+
this._render();
|
|
1582
|
+
}
|
|
1583
|
+
// Esc / Ctrl+C: leave search mode and restore the buffer typed before it.
|
|
1584
|
+
_searchCancel() {
|
|
1585
|
+
this._searchMode = false;
|
|
1586
|
+
this._searchQuery = '';
|
|
1587
|
+
this.buf = this._searchSavedBuf;
|
|
1588
|
+
this.cursor = Array.from(this.buf).length;
|
|
1589
|
+
this.setStatus('');
|
|
1590
|
+
this._render();
|
|
1591
|
+
}
|
|
1592
|
+
// A character typed during search extends the query; Backspace trims it.
|
|
1593
|
+
_searchInput(ch) {
|
|
1594
|
+
this._searchQuery += ch;
|
|
1595
|
+
this._searchRefresh(true);
|
|
1596
|
+
this._searchStatus();
|
|
1597
|
+
}
|
|
1598
|
+
_searchBackspace() {
|
|
1599
|
+
if (!this._searchQuery.length)
|
|
1600
|
+
return;
|
|
1601
|
+
this._searchQuery = this._searchQuery.slice(0, -1);
|
|
1602
|
+
this._searchRefresh(true);
|
|
1603
|
+
this._searchStatus();
|
|
1604
|
+
}
|
|
1495
1605
|
_handle(data) {
|
|
1496
1606
|
let s = data.toString('utf-8');
|
|
1497
1607
|
// While locked (a long operation is running), swallow all input except
|
|
@@ -1513,11 +1623,40 @@ export class LineEditor {
|
|
|
1513
1623
|
}
|
|
1514
1624
|
return;
|
|
1515
1625
|
}
|
|
1516
|
-
if (!this._inPaste && s === ESC) {
|
|
1626
|
+
if (!this._inPaste && s === ESC && !this._searchMode) {
|
|
1517
1627
|
if (this.onEscape)
|
|
1518
1628
|
this.onEscape();
|
|
1519
1629
|
return;
|
|
1520
1630
|
}
|
|
1631
|
+
// While the reverse search (Ctrl+R) is active, the editor is in a modal
|
|
1632
|
+
// state: every printable key extends the query, Ctrl+R scans older
|
|
1633
|
+
// entries, Enter accepts and Esc/Ctrl+C cancels. Handling this BEFORE the
|
|
1634
|
+
// normal key dispatch keeps the search self-contained.
|
|
1635
|
+
if (this._searchMode) {
|
|
1636
|
+
let i = 0;
|
|
1637
|
+
while (i < s.length) {
|
|
1638
|
+
const code = s.charCodeAt(i);
|
|
1639
|
+
if (code === 18) {
|
|
1640
|
+
this._searchNext();
|
|
1641
|
+
}
|
|
1642
|
+
else if (code === 13 || code === 10) {
|
|
1643
|
+
this._searchAccept();
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
else if (code === 27 || code === 3) {
|
|
1647
|
+
this._searchCancel();
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
else if (code === 127 || code === 8) {
|
|
1651
|
+
this._searchBackspace();
|
|
1652
|
+
}
|
|
1653
|
+
else if (code >= 32) {
|
|
1654
|
+
this._searchInput(s[i]);
|
|
1655
|
+
}
|
|
1656
|
+
i++;
|
|
1657
|
+
}
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1521
1660
|
while (s.length) {
|
|
1522
1661
|
if (this._inPaste) {
|
|
1523
1662
|
const end = s.indexOf(PASTE_END);
|
|
@@ -1648,6 +1787,11 @@ export class LineEditor {
|
|
|
1648
1787
|
void this._tryClipboard();
|
|
1649
1788
|
continue;
|
|
1650
1789
|
}
|
|
1790
|
+
if (code === 18) {
|
|
1791
|
+
// Ctrl+R — incremental reverse search over the input history.
|
|
1792
|
+
this._searchStart();
|
|
1793
|
+
continue;
|
|
1794
|
+
}
|
|
1651
1795
|
if (code === 23) {
|
|
1652
1796
|
this._deleteWordLeft();
|
|
1653
1797
|
this._renderInputOnly();
|
package/dist/self-review.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import fs from 'fs/promises';
|
|
3
|
-
import { theme } from './theme.js';
|
|
3
|
+
import { theme, divider } from './theme.js';
|
|
4
4
|
import { translate, DEFAULT_LOCALE } from './i18n.js';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
6
|
import { ZAMES_HOME } from './config.js';
|
|
@@ -116,7 +116,7 @@ export async function selfReview({ browser, focus, transcript, locale, }) {
|
|
|
116
116
|
await fs.writeFile(reportPath, finalMessage || t('self.report_empty'), 'utf-8');
|
|
117
117
|
// Compute what changed
|
|
118
118
|
const changed = await diffFiles(SRC_DIR, snapDir);
|
|
119
|
-
console.log(theme.system(
|
|
119
|
+
console.log(theme.system(divider()));
|
|
120
120
|
console.log(theme.system(t('self.report_path', { v: reportPath })));
|
|
121
121
|
console.log(theme.system(t('self.snapshot_path', { v: snapDir })));
|
|
122
122
|
if (changed.length) {
|
package/dist/spinner.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import ora from 'ora';
|
|
2
|
-
import { theme } from './theme.js';
|
|
2
|
+
import { theme, divider } from './theme.js';
|
|
3
3
|
import { renderMarkdown } from './markdown.js';
|
|
4
4
|
import { translate } from './i18n.js';
|
|
5
5
|
// Phrases for the "agent is working" spinner. Chosen in random order.
|
|
@@ -59,14 +59,9 @@ export function createSpinner(locale = 'ru') {
|
|
|
59
59
|
// "running" dot sequence. Shared by the thinking spinner and the send-pause
|
|
60
60
|
// indicator, so the pause is animated too (before, it was a static line and
|
|
61
61
|
// the dots did not move — the operator saw a frozen spinner).
|
|
62
|
-
|
|
63
|
-
// `showHint` (default true) controls the trailing "· Esc — стоп". While a
|
|
64
|
-
// tool runs we hide it: its process cannot be cancelled, so promising a stop
|
|
65
|
-
// would be misleading.
|
|
66
|
-
const startAnimated = (baseText, showHint = true) => {
|
|
62
|
+
const startAnimated = (baseText) => {
|
|
67
63
|
animBase = theme.brown(stripEllipsis(baseText));
|
|
68
|
-
|
|
69
|
-
start(animBase + renderDots(0) + hint);
|
|
64
|
+
start(animBase + renderDots(0) + HINT);
|
|
70
65
|
dotPhase = 0;
|
|
71
66
|
if (dotTimer)
|
|
72
67
|
clearInterval(dotTimer);
|
|
@@ -74,7 +69,7 @@ export function createSpinner(locale = 'ru') {
|
|
|
74
69
|
if (!spinner)
|
|
75
70
|
return;
|
|
76
71
|
dotPhase = (dotPhase + 1) % DOTS.length;
|
|
77
|
-
spinner.text = animBase + renderDots(dotPhase) +
|
|
72
|
+
spinner.text = animBase + renderDots(dotPhase) + HINT;
|
|
78
73
|
}, 400);
|
|
79
74
|
animating = true;
|
|
80
75
|
if (dotTimer.unref)
|
|
@@ -158,7 +153,7 @@ export function createSpinner(locale = 'ru') {
|
|
|
158
153
|
// (which is highlighted by the prompt with a golden arrow).
|
|
159
154
|
console.log(NL + theme.assistant(translate(locale)('editor.answer')) + NL);
|
|
160
155
|
console.log(rendered);
|
|
161
|
-
console.log(theme.dim(
|
|
156
|
+
console.log(theme.dim(divider()) + NL);
|
|
162
157
|
},
|
|
163
158
|
warning: (msg) => {
|
|
164
159
|
stop();
|
package/dist/theme.js
CHANGED
|
@@ -29,7 +29,16 @@ export const theme = {
|
|
|
29
29
|
success: chalk.hex('#a9c08c'), // soft sage
|
|
30
30
|
brown: chalk.hex('#a1723f'), // brown (status/spinner)
|
|
31
31
|
toggleOn: chalk.hex('#6fd0b0'), // teal-green — a toggle is ON
|
|
32
|
-
toggleOff: chalk.hex('#4a4f5a'), // dim gray — a toggle is OFF
|
|
33
32
|
bold: chalk.bold,
|
|
34
33
|
};
|
|
34
|
+
// Horizontal rule that separates the model's answer from the next block. It
|
|
35
|
+
// spans the terminal width (capped at `max`) so it does not look stubby on a
|
|
36
|
+
// wide terminal. Falls back to 60 columns when the width is unknown (non-TTY).
|
|
37
|
+
// One column is kept free, like everywhere else in the status/input layout, so
|
|
38
|
+
// a full-width rule cannot trigger autowrap.
|
|
39
|
+
export function divider(max = 100) {
|
|
40
|
+
const cols = process.stdout.columns || 0;
|
|
41
|
+
const width = Math.max(20, Math.min(max, cols ? cols - 1 : 60));
|
|
42
|
+
return '─'.repeat(width);
|
|
43
|
+
}
|
|
35
44
|
export default theme;
|