@stevezhou/sisu 0.1.10 → 0.2.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/NOTICE +12 -0
- package/README.md +3 -2
- package/dist/commands.js +58 -47
- package/dist/main.js +18 -5
- package/dist/pager/app.js +90 -5
- package/dist/pager/input.js +43 -20
- package/dist/pager/model.js +100 -25
- package/dist/runtime/adapter.js +164 -0
- package/dist/runtime/index.js +33 -0
- package/dist/runtime/launch.js +94 -0
- package/dist/runtime/loop.js +91 -0
- package/dist/runtime/models.js +56 -0
- package/dist/runtime/sessions.js +65 -0
- package/dist/runtime/suite.js +64 -0
- package/dist/runtime/tools.js +256 -0
- package/dist/runtime/transport.js +93 -0
- package/dist/runtime/types.js +2 -0
- package/dist/store.js +1 -0
- package/dist/transport.js +2 -0
- package/dist/tui.js +25 -3
- package/package.json +4 -3
package/NOTICE
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
SiSu CLI
|
|
2
|
+
Copyright 2026 SiSu / 思溯
|
|
3
|
+
|
|
4
|
+
This product includes grok-build first-party source (Apache-2.0)
|
|
5
|
+
from https://github.com/xai-org/grok-build, Copyright 2023-2026 SpaceXAI.
|
|
6
|
+
|
|
7
|
+
The grok-build tree lives at vendor/grok-build/ together with its
|
|
8
|
+
LICENSE and THIRD-PARTY-NOTICES. SiSu二次开发 replaces xAI login,
|
|
9
|
+
telemetry defaults, branding, and the model/quota endpoint so the
|
|
10
|
+
user-facing binary is SiSu (`sisu`), not the official `grok` client.
|
|
11
|
+
|
|
12
|
+
See vendor/grok-build/NOTICE for the grok-build-specific attribution.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Official publish channel for the SiSu CLI npm package.
|
|
4
4
|
|
|
5
|
-
One login. Cloud quota. Local
|
|
5
|
+
One login. Cloud quota. Local runtime. Auth lives in `~/.sisu`, shared with SiSu Desktop. The CLI is a grok-build-derived local coding agent (read / edit / search / shell + the grok-build extension surface). SiSu cloud plays the Claude/Grok role: device login, model list, billed completions (`POST /api/runtime/complete`). Sessions are local under `~/.sisu/sessions`.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -29,9 +29,10 @@ Default API is `https://www.sisu.chat`. Override with `--api` or `SISU_API_BASE`
|
|
|
29
29
|
## Commands
|
|
30
30
|
|
|
31
31
|
```
|
|
32
|
-
sisu interactive TUI
|
|
32
|
+
sisu interactive TUI (local runtime; Grok Build if the pager binary is present)
|
|
33
33
|
sisu open <dir> --project <id>
|
|
34
34
|
sisu exec "<prompt>"
|
|
35
|
+
sisu -p "<prompt>"
|
|
35
36
|
sisu history
|
|
36
37
|
sisu logout
|
|
37
38
|
```
|
package/dist/commands.js
CHANGED
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.resolveRuntimeModel = exports.resolveCatalogModel = exports.fetchModelCatalog = void 0;
|
|
6
7
|
exports.loginCommand = loginCommand;
|
|
7
8
|
exports.logoutCommand = logoutCommand;
|
|
8
9
|
exports.resolveVerificationUrl = resolveVerificationUrl;
|
|
@@ -18,12 +19,19 @@ exports.execCommand = execCommand;
|
|
|
18
19
|
exports.listConversationsCommand = listConversationsCommand;
|
|
19
20
|
exports.openConversationCommand = openConversationCommand;
|
|
20
21
|
exports.setTrainingCommand = setTrainingCommand;
|
|
22
|
+
exports.listModelsCommand = listModelsCommand;
|
|
23
|
+
exports.setModelCommand = setModelCommand;
|
|
21
24
|
const child_process_1 = require("child_process");
|
|
22
25
|
const fs_1 = __importDefault(require("fs"));
|
|
23
26
|
const path_1 = __importDefault(require("path"));
|
|
24
27
|
const http_1 = require("./http");
|
|
25
|
-
const
|
|
26
|
-
const
|
|
28
|
+
const adapter_1 = require("./runtime/adapter");
|
|
29
|
+
const loop_1 = require("./runtime/loop");
|
|
30
|
+
const models_1 = require("./runtime/models");
|
|
31
|
+
Object.defineProperty(exports, "fetchModelCatalog", { enumerable: true, get: function () { return models_1.fetchModelCatalog; } });
|
|
32
|
+
Object.defineProperty(exports, "resolveCatalogModel", { enumerable: true, get: function () { return models_1.resolveCatalogModel; } });
|
|
33
|
+
Object.defineProperty(exports, "resolveRuntimeModel", { enumerable: true, get: function () { return models_1.resolveRuntimeModel; } });
|
|
34
|
+
const transport_1 = require("./runtime/transport");
|
|
27
35
|
const store_1 = require("./store");
|
|
28
36
|
async function loginCommand(input, http = http_1.defaultHttp) {
|
|
29
37
|
const apiBase = (input.apiBase || process.env.SISU_API_BASE || store_1.DEFAULT_API_BASE).replace(/\/+$/, '');
|
|
@@ -262,56 +270,37 @@ function listLocalCommand(projectId) {
|
|
|
262
270
|
}).join('\n');
|
|
263
271
|
}
|
|
264
272
|
async function execCommand(prompt, options = {}, http = http_1.defaultHttp) {
|
|
265
|
-
const auth = (0, store_1.requireAuth)();
|
|
266
273
|
const text = prompt.trim();
|
|
267
274
|
if (!text)
|
|
268
275
|
throw new Error('prompt is required');
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
throw new Error((0, http_1.errorDetail)(body, `create conversation failed (${created.status})`));
|
|
286
|
-
conversationId = String(body.id || '');
|
|
287
|
-
if (!conversationId)
|
|
288
|
-
throw new Error('create conversation missing id');
|
|
276
|
+
const stub = Boolean(options.stub || process.env.SISU_RUNTIME_STUB === '1');
|
|
277
|
+
const cwd = options.cwd || process.cwd();
|
|
278
|
+
const modelClient = options.modelClient || (stub
|
|
279
|
+
? (0, loop_1.createLaunchStubModel)()
|
|
280
|
+
: (() => {
|
|
281
|
+
const auth = (0, store_1.requireAuth)();
|
|
282
|
+
return (0, adapter_1.createSisuCloudModel)(http, {
|
|
283
|
+
apiBase: auth.api_base,
|
|
284
|
+
token: auth.token,
|
|
285
|
+
client: options.client || 'cli',
|
|
286
|
+
});
|
|
287
|
+
})());
|
|
288
|
+
if (!stub)
|
|
289
|
+
(0, store_1.requireAuth)();
|
|
290
|
+
if (options.projectId) {
|
|
291
|
+
(0, store_1.writeSession)({ ...(0, store_1.readSession)(), last_project_id: options.projectId });
|
|
289
292
|
}
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
client_version: stamp.client_version,
|
|
300
|
-
client_request_id: stamp.client_request_id,
|
|
301
|
-
}),
|
|
293
|
+
const model = await (0, models_1.resolveRuntimeModel)(http, { explicit: options.model, stub });
|
|
294
|
+
const result = await (0, transport_1.execLocalTurn)(text, {
|
|
295
|
+
cwd,
|
|
296
|
+
model,
|
|
297
|
+
conversationId: options.conversationId,
|
|
298
|
+
newConversation: options.newConversation,
|
|
299
|
+
modelClient,
|
|
300
|
+
http,
|
|
301
|
+
client: options.client || 'cli',
|
|
302
302
|
});
|
|
303
|
-
|
|
304
|
-
const body = await sent.json().catch(() => ({}));
|
|
305
|
-
throw new Error((0, http_1.errorDetail)(body, `exec failed (${sent.status})`));
|
|
306
|
-
}
|
|
307
|
-
const stream = await sent.text();
|
|
308
|
-
const output = (0, sse_1.extractSseText)(stream);
|
|
309
|
-
(0, store_1.writeSession)({
|
|
310
|
-
...(0, store_1.readSession)(),
|
|
311
|
-
last_conversation_id: conversationId,
|
|
312
|
-
last_project_id: options.projectId || (0, store_1.readSession)().last_project_id,
|
|
313
|
-
});
|
|
314
|
-
return { conversationId, text: output };
|
|
303
|
+
return { conversationId: result.conversationId, text: result.text };
|
|
315
304
|
}
|
|
316
305
|
async function listConversationsCommand(http = http_1.defaultHttp) {
|
|
317
306
|
const auth = (0, store_1.requireAuth)();
|
|
@@ -348,3 +337,25 @@ async function setTrainingCommand(optIn, http = http_1.defaultHttp) {
|
|
|
348
337
|
throw new Error((0, http_1.errorDetail)(body, `training update failed (${response.status})`));
|
|
349
338
|
return optIn ? 'training opt-in on (new turns may be used if eligible)' : 'training opt-in off';
|
|
350
339
|
}
|
|
340
|
+
async function listModelsCommand(http = http_1.defaultHttp) {
|
|
341
|
+
const { models, defaultModel } = await (0, models_1.fetchModelCatalog)(http);
|
|
342
|
+
const current = (0, store_1.readSession)().last_model || defaultModel;
|
|
343
|
+
if (!current && !models.length)
|
|
344
|
+
return 'no models available';
|
|
345
|
+
const lines = models.map((row) => {
|
|
346
|
+
const mark = row.name === current ? '* ' : ' ';
|
|
347
|
+
const extra = row.label !== row.name ? ` ${row.label}` : '';
|
|
348
|
+
return `${mark}${row.name}${extra}`;
|
|
349
|
+
});
|
|
350
|
+
if (current && !models.some((row) => row.name === current)) {
|
|
351
|
+
lines.unshift(`* ${current}`);
|
|
352
|
+
}
|
|
353
|
+
return lines.join('\n') || `* ${current}`;
|
|
354
|
+
}
|
|
355
|
+
async function setModelCommand(query, http = http_1.defaultHttp) {
|
|
356
|
+
const wanted = query.trim();
|
|
357
|
+
if (!wanted)
|
|
358
|
+
return listModelsCommand(http);
|
|
359
|
+
const name = await (0, models_1.resolveRuntimeModel)(http, { explicit: wanted });
|
|
360
|
+
return `model ${name}`;
|
|
361
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -24,11 +24,14 @@ Usage:
|
|
|
24
24
|
sisu status
|
|
25
25
|
sisu open <dir> --project <project-id>
|
|
26
26
|
sisu ls [--project <project-id>]
|
|
27
|
-
sisu exec "<prompt>" [--project <id>] [--model <name>] [--new]
|
|
27
|
+
sisu exec "<prompt>" [--project <id>] [--model <name>] [--new] [--stub]
|
|
28
|
+
sisu -p "<prompt>" headless local-agent turn (alias of exec)
|
|
29
|
+
sisu models
|
|
30
|
+
sisu model <name>
|
|
28
31
|
sisu history
|
|
29
32
|
sisu thread <conversation-id>
|
|
30
33
|
sisu training --on|--off
|
|
31
|
-
sisu
|
|
34
|
+
sisu Grok Build TUI (SiSu account, models, quota)
|
|
32
35
|
sisu help
|
|
33
36
|
|
|
34
37
|
Auth and workspaces live in $SISU_HOME (default ~/.sisu), shared with Desktop.
|
|
@@ -49,8 +52,8 @@ function parseArgs(args) {
|
|
|
49
52
|
const switches = new Set();
|
|
50
53
|
for (let i = 0; i < args.length; i += 1) {
|
|
51
54
|
const item = args[i];
|
|
52
|
-
if (item === '--new') {
|
|
53
|
-
switches.add('
|
|
55
|
+
if (item === '--new' || item === '--stub' || item === '-p' || item === '--print') {
|
|
56
|
+
switches.add(item.replace(/^-+/, ''));
|
|
54
57
|
continue;
|
|
55
58
|
}
|
|
56
59
|
if (item.startsWith('--')) {
|
|
@@ -121,7 +124,7 @@ async function runCli(argv, deps = {}) {
|
|
|
121
124
|
process.stdout.write(`${(0, commands_1.listLocalCommand)(flag(args, '--project'))}\n`);
|
|
122
125
|
return 0;
|
|
123
126
|
}
|
|
124
|
-
if (command === 'exec') {
|
|
127
|
+
if (command === 'exec' || command === '-p' || command === '--print') {
|
|
125
128
|
const parsed = parseArgs(args);
|
|
126
129
|
const prompt = parsed.rest.join(' ').trim();
|
|
127
130
|
if (!prompt) {
|
|
@@ -132,6 +135,7 @@ async function runCli(argv, deps = {}) {
|
|
|
132
135
|
projectId: parsed.flags['--project'],
|
|
133
136
|
model: parsed.flags['--model'],
|
|
134
137
|
newConversation: parsed.switches.has('new'),
|
|
138
|
+
stub: parsed.switches.has('stub') || process.env.SISU_RUNTIME_STUB === '1',
|
|
135
139
|
});
|
|
136
140
|
if (result.text)
|
|
137
141
|
process.stdout.write(`${result.text}\n`);
|
|
@@ -146,6 +150,15 @@ async function runCli(argv, deps = {}) {
|
|
|
146
150
|
process.stdout.write(`${(0, commands_1.openConversationCommand)(id)}\n`);
|
|
147
151
|
return 0;
|
|
148
152
|
}
|
|
153
|
+
if (command === 'models') {
|
|
154
|
+
process.stdout.write(`${await (0, commands_1.listModelsCommand)(http)}\n`);
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
if (command === 'model') {
|
|
158
|
+
const name = args.find((item) => !item.startsWith('--')) || '';
|
|
159
|
+
process.stdout.write(`${await (0, commands_1.setModelCommand)(name, http)}\n`);
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
149
162
|
if (command === 'training') {
|
|
150
163
|
if (args.includes('--on')) {
|
|
151
164
|
process.stdout.write(`${await (0, commands_1.setTrainingCommand)(true)}\n`);
|
package/dist/pager/app.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.formatChromeStatus = formatChromeStatus;
|
|
4
7
|
exports.chromeShortQuota = chromeShortQuota;
|
|
5
8
|
exports.runPager = runPager;
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
6
11
|
const store_1 = require("../store");
|
|
7
12
|
const input_1 = require("./input");
|
|
8
13
|
const model_1 = require("./model");
|
|
9
14
|
const history_1 = require("./history");
|
|
10
15
|
const logo_1 = require("../logo");
|
|
16
|
+
const text_1 = require("./text");
|
|
11
17
|
const render_1 = require("./render");
|
|
12
18
|
const ALT_ENTER = '\x1b[?1049h\x1b[?25l';
|
|
13
19
|
const ALT_LEAVE = '\x1b[?1049l\x1b[?25h';
|
|
@@ -17,13 +23,16 @@ function shortConversationId(id) {
|
|
|
17
23
|
return '';
|
|
18
24
|
return conv.length > 12 ? conv.slice(0, 8) : conv;
|
|
19
25
|
}
|
|
20
|
-
function formatChromeStatus(email, quota, conversationId = '') {
|
|
26
|
+
function formatChromeStatus(email, quota, conversationId = '', model = '') {
|
|
21
27
|
const who = (email || '').trim();
|
|
22
28
|
const conv = shortConversationId(conversationId);
|
|
29
|
+
const modelName = (model || '').trim();
|
|
23
30
|
if (!who) {
|
|
24
|
-
return conv ? `sisu · not signed in · ${conv}` : 'sisu · not signed in';
|
|
31
|
+
return conv ? `sisu local · not signed in · ${conv}` : 'sisu local · not signed in';
|
|
25
32
|
}
|
|
26
|
-
const parts = [who];
|
|
33
|
+
const parts = [who, 'local'];
|
|
34
|
+
if (modelName)
|
|
35
|
+
parts.push(modelName);
|
|
27
36
|
const quotaText = (quota || '').trim();
|
|
28
37
|
if (quotaText && quotaText !== 'quota unavailable')
|
|
29
38
|
parts.push(quotaText);
|
|
@@ -54,7 +63,7 @@ function pushEntry(state, kind, text) {
|
|
|
54
63
|
return { ...state, entries, selected: entries.length - 1 };
|
|
55
64
|
}
|
|
56
65
|
function clearDraft(state) {
|
|
57
|
-
return { ...state, draft: '', slashOpen: false, slashIndex: 0 };
|
|
66
|
+
return { ...state, draft: '', slashOpen: false, slashIndex: 0, draftIndex: 0, historyIndex: -1, stashDraft: '' };
|
|
58
67
|
}
|
|
59
68
|
function submittedText(state) {
|
|
60
69
|
const raw = state.draft.trim();
|
|
@@ -74,6 +83,8 @@ function commandHead(text) {
|
|
|
74
83
|
return '/new';
|
|
75
84
|
if (token === '/history')
|
|
76
85
|
return '/resume';
|
|
86
|
+
if (token === '/m')
|
|
87
|
+
return '/model';
|
|
77
88
|
return token;
|
|
78
89
|
}
|
|
79
90
|
async function resolveText(value) {
|
|
@@ -85,9 +96,10 @@ async function runPager(io, transport, options = {}) {
|
|
|
85
96
|
let theme = options.theme ?? 'dark';
|
|
86
97
|
let quotaLine = 'quota unavailable';
|
|
87
98
|
let chromeEmail = (options.email || '').trim();
|
|
99
|
+
let chromeModel = ((0, store_1.readSession)().last_model || '').trim();
|
|
88
100
|
const withChrome = (next) => ({
|
|
89
101
|
...next,
|
|
90
|
-
statusLine: formatChromeStatus(chromeEmail, quotaLine, next.conversationId),
|
|
102
|
+
statusLine: formatChromeStatus(chromeEmail, quotaLine, next.conversationId, chromeModel),
|
|
91
103
|
});
|
|
92
104
|
const refreshQuota = async () => {
|
|
93
105
|
if (!options.quota)
|
|
@@ -295,6 +307,79 @@ async function runPager(io, transport, options = {}) {
|
|
|
295
307
|
paint();
|
|
296
308
|
return;
|
|
297
309
|
}
|
|
310
|
+
if (head === '/logout') {
|
|
311
|
+
options.logout?.();
|
|
312
|
+
chromeEmail = '';
|
|
313
|
+
chromeModel = '';
|
|
314
|
+
state = withChrome(state);
|
|
315
|
+
state = pushEntry(state, 'status', 'signed out');
|
|
316
|
+
paint();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (head === '/models' || (head === '/model' && !text.replace(/^\S+\s*/, '').trim())) {
|
|
320
|
+
const body = options.models ? await resolveText(options.models()) : 'not wired';
|
|
321
|
+
if (!running)
|
|
322
|
+
return;
|
|
323
|
+
state = pushEntry(state, 'status', body);
|
|
324
|
+
paint();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (head === '/model') {
|
|
328
|
+
const wanted = text.replace(/^\S+\s*/, '').trim();
|
|
329
|
+
if (!options.setModel) {
|
|
330
|
+
state = pushEntry(state, 'status', 'not wired');
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
try {
|
|
334
|
+
const body = await resolveText(options.setModel(wanted));
|
|
335
|
+
if (!running)
|
|
336
|
+
return;
|
|
337
|
+
chromeModel = ((0, store_1.readSession)().last_model || wanted).trim();
|
|
338
|
+
state = withChrome(state);
|
|
339
|
+
state = pushEntry(state, 'status', body);
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
if (!running)
|
|
343
|
+
return;
|
|
344
|
+
state = pushEntry(state, 'status', err instanceof Error ? err.message : String(err));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
paint();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (head === '/copy') {
|
|
351
|
+
const last = [...state.entries].reverse().find((entry) => entry.kind === 'assistant');
|
|
352
|
+
const body = last ? (0, text_1.visibleAssistantText)(last.text).trim() : '';
|
|
353
|
+
if (!body) {
|
|
354
|
+
state = pushEntry(state, 'status', 'nothing to copy');
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
const dest = path_1.default.join((0, store_1.getSisuHome)(), 'last-copy.txt');
|
|
358
|
+
fs_1.default.mkdirSync((0, store_1.getSisuHome)(), { recursive: true });
|
|
359
|
+
fs_1.default.writeFileSync(dest, `${body}\n`, 'utf8');
|
|
360
|
+
state = pushEntry(state, 'status', `copied to ${dest}`);
|
|
361
|
+
}
|
|
362
|
+
paint();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (head === '/export') {
|
|
366
|
+
const dest = text.replace(/^\S+\s*/, '').trim() || path_1.default.join((0, store_1.getSisuHome)(), 'export.md');
|
|
367
|
+
const body = state.entries
|
|
368
|
+
.map((entry) => {
|
|
369
|
+
if (entry.kind === 'user')
|
|
370
|
+
return `## You\n\n${entry.text}`;
|
|
371
|
+
if (entry.kind === 'assistant')
|
|
372
|
+
return `## SiSu\n\n${(0, text_1.visibleAssistantText)(entry.text)}`;
|
|
373
|
+
return '';
|
|
374
|
+
})
|
|
375
|
+
.filter(Boolean)
|
|
376
|
+
.join('\n\n');
|
|
377
|
+
fs_1.default.mkdirSync(path_1.default.dirname(path_1.default.resolve(dest)), { recursive: true });
|
|
378
|
+
fs_1.default.writeFileSync(path_1.default.resolve(dest), `${body}\n`, 'utf8');
|
|
379
|
+
state = pushEntry(state, 'status', `exported ${path_1.default.resolve(dest)}`);
|
|
380
|
+
paint();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
298
383
|
if (head === '/status') {
|
|
299
384
|
const body = options.status ? await resolveText(options.status()) : 'not wired';
|
|
300
385
|
if (!running)
|
package/dist/pager/input.js
CHANGED
|
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.decodeKeys = decodeKeys;
|
|
4
4
|
/**
|
|
5
5
|
* Decode a raw terminal input chunk into pager keys.
|
|
6
|
-
* Incomplete CSI sequences are returned in `rest` so the next read can finish them.
|
|
6
|
+
* Incomplete CSI/SS3 sequences are returned in `rest` so the next read can finish them.
|
|
7
7
|
* `\x03` (Ctrl+C) maps to escape; the app treats escape at empty draft as quit.
|
|
8
8
|
*/
|
|
9
9
|
function decodeKeys(chunk) {
|
|
@@ -12,37 +12,52 @@ function decodeKeys(chunk) {
|
|
|
12
12
|
while (i < chunk.length) {
|
|
13
13
|
const ch = chunk[i];
|
|
14
14
|
if (ch === '\x1b') {
|
|
15
|
-
// Incomplete CSI prefix — hold for the next chunk.
|
|
16
15
|
if (i + 1 === chunk.length) {
|
|
17
16
|
keys.push({ type: 'escape' });
|
|
18
17
|
i += 1;
|
|
19
18
|
continue;
|
|
20
19
|
}
|
|
21
|
-
if (chunk[i + 1] === '
|
|
22
|
-
if (i + 2 >= chunk.length)
|
|
20
|
+
if (chunk[i + 1] === 'O') {
|
|
21
|
+
if (i + 2 >= chunk.length)
|
|
23
22
|
return { keys, rest: chunk.slice(i) };
|
|
24
|
-
|
|
25
|
-
const code = chunk[i + 2];
|
|
26
|
-
const arrow = code === 'A'
|
|
27
|
-
? 'up'
|
|
28
|
-
: code === 'B'
|
|
29
|
-
? 'down'
|
|
30
|
-
: code === 'C'
|
|
31
|
-
? 'right'
|
|
32
|
-
: code === 'D'
|
|
33
|
-
? 'left'
|
|
34
|
-
: null;
|
|
23
|
+
const arrow = arrowFrom(chunk[i + 2]);
|
|
35
24
|
if (arrow) {
|
|
36
25
|
keys.push({ type: arrow });
|
|
37
26
|
i += 3;
|
|
38
27
|
continue;
|
|
39
28
|
}
|
|
40
|
-
// Unknown completed CSI: treat ESC as escape and rescan from '['.
|
|
41
29
|
keys.push({ type: 'escape' });
|
|
42
30
|
i += 1;
|
|
43
31
|
continue;
|
|
44
32
|
}
|
|
45
|
-
|
|
33
|
+
if (chunk[i + 1] === '[') {
|
|
34
|
+
let j = i + 2;
|
|
35
|
+
while (j < chunk.length && /[0-9;]/.test(chunk[j]))
|
|
36
|
+
j += 1;
|
|
37
|
+
if (j >= chunk.length)
|
|
38
|
+
return { keys, rest: chunk.slice(i) };
|
|
39
|
+
const body = chunk.slice(i + 2, j);
|
|
40
|
+
const term = chunk[j];
|
|
41
|
+
const arrow = arrowFrom(term);
|
|
42
|
+
if (arrow) {
|
|
43
|
+
keys.push({ type: arrow });
|
|
44
|
+
i = j + 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (term === '~' && body === '5') {
|
|
48
|
+
keys.push({ type: 'pageup' });
|
|
49
|
+
i = j + 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (term === '~' && body === '6') {
|
|
53
|
+
keys.push({ type: 'pagedown' });
|
|
54
|
+
i = j + 1;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
keys.push({ type: 'escape' });
|
|
58
|
+
i += 1;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
46
61
|
keys.push({ type: 'escape' });
|
|
47
62
|
i += 1;
|
|
48
63
|
continue;
|
|
@@ -57,19 +72,16 @@ function decodeKeys(chunk) {
|
|
|
57
72
|
i += 1;
|
|
58
73
|
continue;
|
|
59
74
|
}
|
|
60
|
-
// Ctrl+C → escape (app treats escape at empty draft as quit)
|
|
61
75
|
if (ch === '\x03') {
|
|
62
76
|
keys.push({ type: 'escape' });
|
|
63
77
|
i += 1;
|
|
64
78
|
continue;
|
|
65
79
|
}
|
|
66
|
-
// Skip other C0 controls (except those handled above)
|
|
67
80
|
const code = ch.charCodeAt(0);
|
|
68
81
|
if (code < 0x20 || code === 0x7f) {
|
|
69
82
|
i += 1;
|
|
70
83
|
continue;
|
|
71
84
|
}
|
|
72
|
-
// Printable UTF-16 code unit / surrogate pair as one char
|
|
73
85
|
const cp = chunk.codePointAt(i);
|
|
74
86
|
if (cp === undefined) {
|
|
75
87
|
i += 1;
|
|
@@ -81,3 +93,14 @@ function decodeKeys(chunk) {
|
|
|
81
93
|
}
|
|
82
94
|
return { keys, rest: '' };
|
|
83
95
|
}
|
|
96
|
+
function arrowFrom(code) {
|
|
97
|
+
if (code === 'A')
|
|
98
|
+
return 'up';
|
|
99
|
+
if (code === 'B')
|
|
100
|
+
return 'down';
|
|
101
|
+
if (code === 'C')
|
|
102
|
+
return 'right';
|
|
103
|
+
if (code === 'D')
|
|
104
|
+
return 'left';
|
|
105
|
+
return null;
|
|
106
|
+
}
|