@carjms/codexswitch 0.6.3 → 0.7.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/README.ko.md +10 -3
- package/README.md +10 -3
- package/package.json +1 -1
- package/src/cli.js +478 -38
- package/src/proxy.js +52 -7
- package/src/store.js +78 -0
- package/src/util.js +30 -3
- package/src/welcome.js +184 -0
package/src/proxy.js
CHANGED
|
@@ -16,6 +16,7 @@ const store = require('./store.js');
|
|
|
16
16
|
|
|
17
17
|
const DEFAULT_PORT = 8437;
|
|
18
18
|
const MAX_BODY = 32 * 1024 * 1024;
|
|
19
|
+
const RATE_LIMIT_ABSORB_MAX_MS = 60000;
|
|
19
20
|
|
|
20
21
|
function upstreamBase() {
|
|
21
22
|
return new URL(process.env.CODEX_SWITCH_UPSTREAM || 'https://chatgpt.com');
|
|
@@ -62,6 +63,29 @@ function usageFromHeaders(headers) {
|
|
|
62
63
|
return { p5h, weekly, at };
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
function retryAfterMs(headers, now = Date.now()) {
|
|
67
|
+
const value = headers['retry-after'];
|
|
68
|
+
if (value == null) return null;
|
|
69
|
+
const seconds = Number(value);
|
|
70
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
|
71
|
+
const date = Date.parse(value);
|
|
72
|
+
return Number.isNaN(date) ? null : Math.max(0, date - now);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Inspired by teamclaude: a quota rejection should rotate immediately, while
|
|
76
|
+
// a short per-minute throttle should stay on the same account to preserve its
|
|
77
|
+
// prompt cache and avoid cascading the burst across every account.
|
|
78
|
+
function classify429(headers, usage, meta) {
|
|
79
|
+
const rejected = Object.entries(headers).some(
|
|
80
|
+
([key, value]) => /(?:codex|quota|limit).*(?:status|state)/i.test(key) && /rejected|exhausted|quota/i.test(String(value))
|
|
81
|
+
);
|
|
82
|
+
const overQuota = [
|
|
83
|
+
usage && usage.p5h && [usage.p5h.pct, meta.threshold5h],
|
|
84
|
+
usage && usage.weekly && [usage.weekly.pct, meta.thresholdWeekly],
|
|
85
|
+
].some((pair) => pair && pair[0] >= pair[1]);
|
|
86
|
+
return rejected || overQuota ? 'quota' : 'rate';
|
|
87
|
+
}
|
|
88
|
+
|
|
65
89
|
function swapHeaders(headers, acct, host) {
|
|
66
90
|
const h = {};
|
|
67
91
|
for (const [k, v] of Object.entries(headers)) {
|
|
@@ -129,6 +153,7 @@ function startServer({ port = DEFAULT_PORT, log = () => {} } = {}) {
|
|
|
129
153
|
const meta = store.loadMeta();
|
|
130
154
|
const total = store.listAccounts().length;
|
|
131
155
|
const tried = [];
|
|
156
|
+
const absorbed = new Set();
|
|
132
157
|
for (let attempt = 0; attempt < Math.max(1, total); attempt++) {
|
|
133
158
|
const acct = pickAuth(tried);
|
|
134
159
|
if (!acct) {
|
|
@@ -147,12 +172,32 @@ function startServer({ port = DEFAULT_PORT, log = () => {} } = {}) {
|
|
|
147
172
|
}
|
|
148
173
|
const usage = usageFromHeaders(upRes.headers);
|
|
149
174
|
if (usage) store.saveUsage(acct.name, usage);
|
|
150
|
-
if (upRes.statusCode === 429
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
175
|
+
if (upRes.statusCode === 429) {
|
|
176
|
+
const kind = classify429(upRes.headers, usage, meta);
|
|
177
|
+
const retryMs = retryAfterMs(upRes.headers);
|
|
178
|
+
if (kind === 'rate') {
|
|
179
|
+
if (retryMs != null && retryMs <= RATE_LIMIT_ABSORB_MAX_MS && !absorbed.has(acct.name)) {
|
|
180
|
+
absorbed.add(acct.name);
|
|
181
|
+
log('rate', `${acct.name} throttled for ${Math.ceil(retryMs)}ms — retrying same account`);
|
|
182
|
+
upRes.resume();
|
|
183
|
+
await new Promise((resolve) => setTimeout(resolve, retryMs));
|
|
184
|
+
tried.pop();
|
|
185
|
+
attempt--;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const pauseMs = retryMs == null ? Math.min(meta.cooldownMinutes * 60000, RATE_LIMIT_ABSORB_MAX_MS) : retryMs;
|
|
189
|
+
store.markLimited(acct.name, Date.now() + pauseMs);
|
|
190
|
+
store.logEvent('limit', `proxy: "${acct.name}" temporarily throttled — not rotating`);
|
|
191
|
+
log('rate', `${acct.name} temporarily throttled — preserving account cache`);
|
|
192
|
+
} else if (tried.length < total) {
|
|
193
|
+
store.markLimited(acct.name, Date.now() + meta.cooldownMinutes * 60000);
|
|
194
|
+
store.logEvent('limit', `proxy: "${acct.name}" quota exhausted — rotating`);
|
|
195
|
+
log('rotate', `${acct.name} quota exhausted -> trying next account`);
|
|
196
|
+
upRes.resume();
|
|
197
|
+
continue;
|
|
198
|
+
} else {
|
|
199
|
+
store.markLimited(acct.name, Date.now() + meta.cooldownMinutes * 60000);
|
|
200
|
+
}
|
|
156
201
|
}
|
|
157
202
|
log('req', `${acct.name} ${req.method} ${req.url} -> ${upRes.statusCode}`);
|
|
158
203
|
res.writeHead(upRes.statusCode, upRes.headers);
|
|
@@ -217,4 +262,4 @@ function ping(port) {
|
|
|
217
262
|
});
|
|
218
263
|
}
|
|
219
264
|
|
|
220
|
-
module.exports = { startServer, ping, DEFAULT_PORT, usageFromHeaders };
|
|
265
|
+
module.exports = { startServer, ping, DEFAULT_PORT, usageFromHeaders, retryAfterMs, classify429 };
|
package/src/store.js
CHANGED
|
@@ -19,6 +19,7 @@ function paths() {
|
|
|
19
19
|
codexHome,
|
|
20
20
|
accountsDir: path.join(home, 'accounts'),
|
|
21
21
|
profilesDir: path.join(home, 'profiles'),
|
|
22
|
+
memoryDir: path.join(home, 'memory'),
|
|
22
23
|
metaPath: path.join(home, 'meta.json'),
|
|
23
24
|
authPath: path.join(codexHome, 'auth.json'),
|
|
24
25
|
};
|
|
@@ -64,10 +65,80 @@ function readEvents(count = 20) {
|
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
// Structured work history is separate from the operational activity log.
|
|
69
|
+
// It intentionally contains user prompts, so the file is created private.
|
|
70
|
+
function logRun(record) {
|
|
71
|
+
try {
|
|
72
|
+
ensureDir(paths().home);
|
|
73
|
+
fs.appendFileSync(path.join(paths().home, 'history.jsonl'), JSON.stringify(record) + '\n', { mode: 0o600 });
|
|
74
|
+
} catch {
|
|
75
|
+
/* history must never break an actual Codex run */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readRuns(count = 20) {
|
|
80
|
+
try {
|
|
81
|
+
const lines = fs.readFileSync(path.join(paths().home, 'history.jsonl'), 'utf8').trim().split('\n');
|
|
82
|
+
return lines.slice(-count).map((line) => {
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(line);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}).filter(Boolean).reverse();
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function findRun(id) {
|
|
95
|
+
const runs = readRuns(1000);
|
|
96
|
+
if (id === 'latest') return runs[0] || null;
|
|
97
|
+
return runs.find((run) => run.id === id || run.id.startsWith(id)) || null;
|
|
98
|
+
}
|
|
99
|
+
|
|
67
100
|
function saveMeta(meta) {
|
|
68
101
|
writeJSONAtomic(paths().metaPath, meta);
|
|
69
102
|
}
|
|
70
103
|
|
|
104
|
+
function memoryPath(mode, accountName = null) {
|
|
105
|
+
const p = paths();
|
|
106
|
+
if (mode === 'shared') return path.join(p.memoryDir, 'shared.md');
|
|
107
|
+
if (mode === 'isolated') {
|
|
108
|
+
if (!accountName) throw new Error('isolated memory requires an account name');
|
|
109
|
+
// Reuse the account-name validation used for credential filenames.
|
|
110
|
+
accountPath(accountName);
|
|
111
|
+
return path.join(p.memoryDir, 'accounts', `${accountName}.md`);
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readMemory(mode, accountName = null) {
|
|
117
|
+
const file = memoryPath(mode, accountName);
|
|
118
|
+
if (!file) return '';
|
|
119
|
+
try {
|
|
120
|
+
// Bound prompt growth even if the file was edited externally.
|
|
121
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
122
|
+
return text.length > 32768 ? text.slice(-32768) : text;
|
|
123
|
+
} catch {
|
|
124
|
+
return '';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function appendMemory(mode, accountName, text) {
|
|
129
|
+
const file = memoryPath(mode, accountName);
|
|
130
|
+
if (!file) throw new Error('memory is off — enable it with "codexswitch memory shared" or "memory isolated"');
|
|
131
|
+
const clean = String(text || '').trim();
|
|
132
|
+
if (!clean) throw new Error('memory text cannot be empty');
|
|
133
|
+
ensureDir(path.dirname(file));
|
|
134
|
+
if (!fs.existsSync(file)) {
|
|
135
|
+
const title = mode === 'shared' ? '# Shared Codex memory\n' : `# Memory for ${accountName}\n`;
|
|
136
|
+
fs.writeFileSync(file, `${title}\n`, { mode: 0o600 });
|
|
137
|
+
}
|
|
138
|
+
fs.appendFileSync(file, `- ${clean.replace(/\n/g, '\n ')}\n`, { mode: 0o600 });
|
|
139
|
+
return file;
|
|
140
|
+
}
|
|
141
|
+
|
|
71
142
|
function accountPath(name) {
|
|
72
143
|
// Unicode letters/digits are fine (names become filenames); block path
|
|
73
144
|
// separators, traversal, and characters invalid on Windows filesystems.
|
|
@@ -268,10 +339,17 @@ module.exports = {
|
|
|
268
339
|
nextAccount,
|
|
269
340
|
logEvent,
|
|
270
341
|
readEvents,
|
|
342
|
+
logRun,
|
|
343
|
+
readRuns,
|
|
344
|
+
findRun,
|
|
345
|
+
memoryPath,
|
|
346
|
+
readMemory,
|
|
347
|
+
appendMemory,
|
|
271
348
|
ensureDirs() {
|
|
272
349
|
const p = paths();
|
|
273
350
|
ensureDir(p.home);
|
|
274
351
|
ensureDir(p.accountsDir);
|
|
275
352
|
ensureDir(p.profilesDir);
|
|
353
|
+
ensureDir(p.memoryDir);
|
|
276
354
|
},
|
|
277
355
|
};
|
package/src/util.js
CHANGED
|
@@ -89,11 +89,37 @@ function fmtRemaining(untilTs) {
|
|
|
89
89
|
return h > 0 ? `${h}h${m}m` : `${m}m`;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// Terminal columns are not the same as JavaScript string length: Korean,
|
|
93
|
+
// Japanese, Chinese and emoji commonly occupy two columns. Keeping this
|
|
94
|
+
// small and dependency-free is sufficient for account names and table data.
|
|
95
|
+
function displayWidth(value) {
|
|
96
|
+
const text = require('./ui.js').visible(String(value));
|
|
97
|
+
let width = 0;
|
|
98
|
+
for (const ch of text) {
|
|
99
|
+
const cp = ch.codePointAt(0);
|
|
100
|
+
// Combining marks and variation selectors do not advance the cursor.
|
|
101
|
+
if (/\p{Mark}/u.test(ch) || (cp >= 0xfe00 && cp <= 0xfe0f)) continue;
|
|
102
|
+
const wide =
|
|
103
|
+
cp >= 0x1100 &&
|
|
104
|
+
(cp <= 0x115f ||
|
|
105
|
+
cp === 0x2329 || cp === 0x232a ||
|
|
106
|
+
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
107
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
108
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
109
|
+
(cp >= 0xfe10 && cp <= 0xfe6f) ||
|
|
110
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
111
|
+
(cp >= 0x1f300 && cp <= 0x1faff) ||
|
|
112
|
+
(cp >= 0x20000 && cp <= 0x3fffd));
|
|
113
|
+
width += wide ? 2 : 1;
|
|
114
|
+
}
|
|
115
|
+
return width;
|
|
116
|
+
}
|
|
117
|
+
|
|
92
118
|
function table(rows, headers) {
|
|
93
|
-
const {
|
|
119
|
+
const { dim } = require('./ui.js');
|
|
94
120
|
const all = [headers, ...rows].map((r) => r.map((c) => String(c == null ? '-' : c)));
|
|
95
|
-
const widths = headers.map((_, i) => Math.max(...all.map((r) =>
|
|
96
|
-
const pad = (c, w) => c + ' '.repeat(Math.max(0, w -
|
|
121
|
+
const widths = headers.map((_, i) => Math.max(...all.map((r) => displayWidth(r[i]))));
|
|
122
|
+
const pad = (c, w) => c + ' '.repeat(Math.max(0, w - displayWidth(c)));
|
|
97
123
|
const line = (r) => r.map((c, i) => pad(c, widths[i])).join(' ').trimEnd();
|
|
98
124
|
return [dim(line(all[0])), dim(line(widths.map((w) => '-'.repeat(w)))), ...all.slice(1).map(line)].join('\n');
|
|
99
125
|
}
|
|
@@ -108,5 +134,6 @@ module.exports = {
|
|
|
108
134
|
parseDurationMs,
|
|
109
135
|
fmtDate,
|
|
110
136
|
fmtRemaining,
|
|
137
|
+
displayWidth,
|
|
111
138
|
table,
|
|
112
139
|
};
|
package/src/welcome.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
5
|
+
const store = require('./store.js');
|
|
6
|
+
const ui = require('./ui.js');
|
|
7
|
+
const { displayWidth, fmtRemaining } = require('./util.js');
|
|
8
|
+
const pkg = require('../package.json');
|
|
9
|
+
|
|
10
|
+
const MIN_WIDTH = 76;
|
|
11
|
+
const MIN_HEIGHT = 22;
|
|
12
|
+
|
|
13
|
+
function crop(value, width) {
|
|
14
|
+
const text = String(value == null ? '' : value);
|
|
15
|
+
if (displayWidth(text) <= width) return text;
|
|
16
|
+
let out = '';
|
|
17
|
+
for (const ch of text) {
|
|
18
|
+
if (displayWidth(out + ch + '…') > width) break;
|
|
19
|
+
out += ch;
|
|
20
|
+
}
|
|
21
|
+
return out + '…';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fit(value, width) {
|
|
25
|
+
const text = crop(value, width);
|
|
26
|
+
return text + ' '.repeat(Math.max(0, width - displayWidth(text)));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function center(value, width) {
|
|
30
|
+
const text = crop(value, width);
|
|
31
|
+
const gap = Math.max(0, width - displayWidth(text));
|
|
32
|
+
return ' '.repeat(Math.floor(gap / 2)) + text + ' '.repeat(Math.ceil(gap / 2));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function percent(account) {
|
|
36
|
+
const win = account.usage && account.usage.p5h;
|
|
37
|
+
return win && typeof win.pct === 'number' ? `${Math.round(win.pct)}%` : '--';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function accountState(account, now = Date.now()) {
|
|
41
|
+
if (account.disabled) return 'disabled';
|
|
42
|
+
if (account.limitedUntil && account.limitedUntil > now) return `paused ${fmtRemaining(account.limitedUntil)}`;
|
|
43
|
+
return 'ready';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function projectPulse(cwd = process.cwd()) {
|
|
47
|
+
const branch = spawnSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf8' });
|
|
48
|
+
const status = spawnSync('git', ['status', '--short'], { cwd, encoding: 'utf8' });
|
|
49
|
+
return {
|
|
50
|
+
branch: branch.status === 0 && branch.stdout.trim() ? branch.stdout.trim() : '(not a git repository)',
|
|
51
|
+
changes: status.status === 0 && status.stdout.trim() ? status.stdout.trim().split(/\r?\n/).length : 0,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function welcomeState() {
|
|
56
|
+
const meta = store.loadMeta();
|
|
57
|
+
const accounts = store.listAccounts();
|
|
58
|
+
const active = accounts.find((a) => a.active) || store.pickAccount() || accounts[0] || null;
|
|
59
|
+
return {
|
|
60
|
+
version: pkg.version,
|
|
61
|
+
accounts,
|
|
62
|
+
active,
|
|
63
|
+
model: meta.model || 'default',
|
|
64
|
+
reasoning: meta.reasoning || 'show',
|
|
65
|
+
memory: meta.memoryMode || 'off',
|
|
66
|
+
output: meta.outputMode || 'auto',
|
|
67
|
+
history: store.readRuns(3),
|
|
68
|
+
project: projectPulse(),
|
|
69
|
+
cwd: process.cwd(),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildWelcomeFrame({ width, height, input = '', state = welcomeState() }) {
|
|
74
|
+
if (width < MIN_WIDTH || height < MIN_HEIGHT) return null;
|
|
75
|
+
const inner = width - 2;
|
|
76
|
+
const leftW = Math.max(34, Math.floor(inner * 0.49));
|
|
77
|
+
const rightW = inner - leftW - 1;
|
|
78
|
+
const bodyH = height - 6;
|
|
79
|
+
const left = [];
|
|
80
|
+
const right = [];
|
|
81
|
+
|
|
82
|
+
const logo = [
|
|
83
|
+
' ██████╗██╗ ██╗███████╗',
|
|
84
|
+
'██╔════╝╚██╗██╔╝██╔════╝',
|
|
85
|
+
'██║ ╚███╔╝ ███████╗',
|
|
86
|
+
'██║ ██╔██╗ ╚════██║',
|
|
87
|
+
'╚██████╗██╔╝ ██╗███████║',
|
|
88
|
+
' ╚═════╝╚═╝ ╚═╝╚══════╝',
|
|
89
|
+
];
|
|
90
|
+
const topPad = Math.max(1, Math.floor((bodyH - 14) / 2));
|
|
91
|
+
for (let i = 0; i < topPad; i++) left.push('');
|
|
92
|
+
left.push(center(ui.bold(ui.cyan('CODEX SWITCH')), leftW));
|
|
93
|
+
left.push(center(ui.dim('rotate · continue · remember'), leftW));
|
|
94
|
+
left.push('');
|
|
95
|
+
logo.forEach((line) => left.push(center(ui.cyan(line), leftW)));
|
|
96
|
+
left.push('');
|
|
97
|
+
left.push(center(state.active ? `${ui.green('●')} ${ui.bold(state.active.name)} · ${accountState(state.active)} · ${percent(state.active)}` : ui.yellow('No usable account'), leftW));
|
|
98
|
+
left.push(center(ui.dim(`${state.accounts.length} account(s) · memory ${state.memory}`), leftW));
|
|
99
|
+
|
|
100
|
+
right.push(ui.bold(ui.cyan('Quick commands')));
|
|
101
|
+
right.push(ui.dim('/help /usage /history /output /memory /new'));
|
|
102
|
+
right.push(ui.dim('Enter send · /paste multiline · /quit exit'));
|
|
103
|
+
right.push(ui.dim('─'.repeat(Math.max(1, rightW))));
|
|
104
|
+
right.push(ui.bold(ui.cyan('Account pool')));
|
|
105
|
+
for (const account of state.accounts.slice(0, 5)) {
|
|
106
|
+
const mark = account.active ? ui.green('●') : '○';
|
|
107
|
+
right.push(`${mark} ${account.name} ${ui.dim(`${accountState(account)} · 5h ${percent(account)}`)}`);
|
|
108
|
+
}
|
|
109
|
+
if (state.accounts.length > 5) right.push(ui.dim(`… ${state.accounts.length - 5} more`));
|
|
110
|
+
right.push(ui.dim('─'.repeat(Math.max(1, rightW))));
|
|
111
|
+
right.push(ui.bold(ui.cyan('Project pulse')));
|
|
112
|
+
right.push(`branch ${ui.bold(state.project.branch)} · ${state.project.changes} change(s)`);
|
|
113
|
+
right.push(ui.dim(crop(state.cwd, rightW)));
|
|
114
|
+
right.push(ui.dim('─'.repeat(Math.max(1, rightW))));
|
|
115
|
+
right.push(ui.bold(ui.cyan('Session trail')));
|
|
116
|
+
if (state.history.length === 0) right.push(ui.dim('No saved work history'));
|
|
117
|
+
else state.history.forEach((run) => right.push(`${run.status === 'done' ? ui.green('✓') : ui.yellow('!')} ${crop(run.prompt || '(no prompt)', Math.max(8, rightW - 13))} ${ui.dim(run.id)}`));
|
|
118
|
+
|
|
119
|
+
while (left.length < bodyH) left.push('');
|
|
120
|
+
while (right.length < bodyH) right.push('');
|
|
121
|
+
const title = ` cxs v${state.version} · persistent Codex workspace `;
|
|
122
|
+
const topLeft = Math.max(0, leftW - displayWidth(title) - 1);
|
|
123
|
+
const lines = [`┌─${title}${'─'.repeat(topLeft)}┬${'─'.repeat(rightW)}┐`];
|
|
124
|
+
for (let i = 0; i < bodyH; i++) lines.push(`│${fit(left[i], leftW)}│${fit(right[i], rightW)}│`);
|
|
125
|
+
lines.push(`└${'─'.repeat(leftW)}┴${'─'.repeat(rightW)}┘`);
|
|
126
|
+
|
|
127
|
+
const status = state.active
|
|
128
|
+
? `${ui.green('●')} ${state.active.name} · model ${state.model} · reasoning ${state.reasoning} · ${crop(state.cwd, Math.max(8, width - 48))}`
|
|
129
|
+
: ui.yellow('No usable account');
|
|
130
|
+
lines.push(fit(status, width));
|
|
131
|
+
lines.push(`┌${'─'.repeat(width - 2)}┐`);
|
|
132
|
+
const prompt = input ? `› ${input}` : ui.dim('› Type your message… Enter: send · /help: commands · Ctrl-C: exit');
|
|
133
|
+
lines.push(`│${fit(prompt, width - 2)}│`);
|
|
134
|
+
lines.push(`└${'─'.repeat(width - 2)}┘`);
|
|
135
|
+
return lines.slice(0, height).map((line) => fit(line, width)).join('\r\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function readWelcomePrompt() {
|
|
139
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY || process.env.CXS_SIMPLE_UI === '1') return { prompt: null, shown: false };
|
|
140
|
+
if ((process.stdout.columns || 80) < MIN_WIDTH || (process.stdout.rows || 24) < MIN_HEIGHT) return { prompt: null, shown: false };
|
|
141
|
+
let input = '';
|
|
142
|
+
let done = false;
|
|
143
|
+
const draw = () => {
|
|
144
|
+
const frame = buildWelcomeFrame({
|
|
145
|
+
width: process.stdout.columns || 80,
|
|
146
|
+
height: process.stdout.rows || 24,
|
|
147
|
+
input,
|
|
148
|
+
});
|
|
149
|
+
if (frame) process.stdout.write(`\x1b[H${frame}`);
|
|
150
|
+
};
|
|
151
|
+
process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H\x1b[?25h');
|
|
152
|
+
process.stdin.setRawMode(true);
|
|
153
|
+
process.stdin.resume();
|
|
154
|
+
process.stdin.setEncoding('utf8');
|
|
155
|
+
draw();
|
|
156
|
+
const prompt = await new Promise((resolve) => {
|
|
157
|
+
const onResize = () => draw();
|
|
158
|
+
const finish = (value) => {
|
|
159
|
+
if (done) return;
|
|
160
|
+
done = true;
|
|
161
|
+
process.stdin.removeListener('data', onData);
|
|
162
|
+
process.stdout.removeListener('resize', onResize);
|
|
163
|
+
resolve(value);
|
|
164
|
+
};
|
|
165
|
+
const onData = (data) => {
|
|
166
|
+
if (data === '\u0003' || data === '\u0004' || data === '\u001b') return finish(null);
|
|
167
|
+
if (data === '\r' || data === '\n') {
|
|
168
|
+
if (input.trim()) finish(input.trim());
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (data === '\u007f' || data === '\b') input = Array.from(input).slice(0, -1).join('');
|
|
172
|
+
else if (!data.startsWith('\u001b')) input += Array.from(data).filter((ch) => ch >= ' ' && ch !== '\u007f').join('');
|
|
173
|
+
draw();
|
|
174
|
+
};
|
|
175
|
+
process.stdin.on('data', onData);
|
|
176
|
+
process.stdout.on('resize', onResize);
|
|
177
|
+
});
|
|
178
|
+
try { process.stdin.setRawMode(false); } catch { /* best effort */ }
|
|
179
|
+
process.stdin.pause();
|
|
180
|
+
process.stdout.write('\x1b[?25h\x1b[?1049l');
|
|
181
|
+
return { prompt, shown: true };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { buildWelcomeFrame, readWelcomePrompt, welcomeState, MIN_WIDTH, MIN_HEIGHT };
|