@xingjisoft/neutrino 0.1.4 → 0.1.6
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.md +413 -163
- package/assets/prompt.txt +4 -0
- package/dist/agents.js +114 -15
- package/dist/app.js +442 -30
- package/dist/bin.js +177 -5
- package/dist/colors.js +2 -0
- package/dist/compact.js +19 -0
- package/dist/config.js +121 -28
- package/dist/cwd.js +54 -0
- package/dist/header.js +8 -0
- package/dist/llm/approval.js +543 -0
- package/dist/llm/client.js +14 -0
- package/dist/llm/risk.js +377 -0
- package/dist/llm/tools.js +37 -21
- package/dist/persistence.js +51 -0
- package/dist/screens/agentHistory.js +7 -4
- package/dist/screens/chat.js +709 -49
- package/dist/screens/history.js +9 -7
- package/dist/screens/settings.js +131 -14
- package/dist/screens/start.js +50 -16
- package/dist/selection.js +60 -0
- package/dist/state.js +1 -0
- package/dist/terminal/input.js +34 -5
- package/dist/terminal/renderer.js +58 -12
- package/dist/terminal/restore.js +148 -0
- package/dist/ui.js +16 -0
- package/dist/web/auth.js +82 -0
- package/dist/web/hub.js +180 -0
- package/dist/web/server.js +688 -0
- package/dist/web/session.js +572 -0
- package/dist/web/settings.js +101 -0
- package/dist/width.js +20 -0
- package/package.json +6 -4
- package/web/README.md +210 -0
- package/web/app.js +1100 -0
- package/web/index.html +90 -0
- package/web/settings.js +193 -0
- package/web/style.css +1088 -0
- package/web/turns.js +52 -0
package/dist/screens/history.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Screen } from "../terminal/screen.js";
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
|
-
import { deleteSession,
|
|
3
|
+
import { deleteSession, listSessionsForCwd, loadSession } from "../config.js";
|
|
4
|
+
import { ellipsizeStart } from "../width.js";
|
|
4
5
|
import { drawHeader } from "../header.js";
|
|
5
6
|
import { drawHint } from "../ui.js";
|
|
6
7
|
const LIST_TOP = 5;
|
|
@@ -12,8 +13,11 @@ export class HistoryScreen extends Screen {
|
|
|
12
13
|
lastHoverY = -1;
|
|
13
14
|
rows = [];
|
|
14
15
|
itemBounds = [];
|
|
16
|
+
cwd = process.cwd();
|
|
15
17
|
onActivate() {
|
|
16
|
-
|
|
18
|
+
// Only sessions started in the current working directory are listed.
|
|
19
|
+
this.cwd = process.cwd();
|
|
20
|
+
this.sessions = listSessionsForCwd(this.cwd);
|
|
17
21
|
this.selectedIdx = this.sessions.length > 0 ? 1 : 0;
|
|
18
22
|
this.scrollTop = 0;
|
|
19
23
|
this.rows = [];
|
|
@@ -104,7 +108,7 @@ export class HistoryScreen extends Screen {
|
|
|
104
108
|
if (!session)
|
|
105
109
|
return;
|
|
106
110
|
deleteSession(session.id);
|
|
107
|
-
this.sessions =
|
|
111
|
+
this.sessions = listSessionsForCwd(this.cwd);
|
|
108
112
|
this.selectedIdx = Math.min(this.selectedIdx, this.sessions.length);
|
|
109
113
|
this.app.render();
|
|
110
114
|
}
|
|
@@ -120,8 +124,6 @@ export class HistoryScreen extends Screen {
|
|
|
120
124
|
rows.push(`${created} | ${s.provider}/${s.model} (${s.intensity}) | ${s.message_count} msgs`);
|
|
121
125
|
if (s.title)
|
|
122
126
|
rows.push(` ${s.title}`);
|
|
123
|
-
if (s.cwd)
|
|
124
|
-
rows.push(` ${s.cwd}`);
|
|
125
127
|
itemBounds.push({ start, end: rows.length });
|
|
126
128
|
}
|
|
127
129
|
this.rows = rows;
|
|
@@ -132,7 +134,7 @@ export class HistoryScreen extends Screen {
|
|
|
132
134
|
const w = buf.width;
|
|
133
135
|
const h = buf.height;
|
|
134
136
|
buf.centerText(2, "History", { fg: C.accent, bold: true });
|
|
135
|
-
buf.centerText(3,
|
|
137
|
+
buf.centerText(3, `Sessions in ${ellipsizeStart(this.cwd, Math.max(8, w - 14))}`, { fg: C.dim });
|
|
136
138
|
const listBottom = h - 2;
|
|
137
139
|
const viewH = Math.max(1, listBottom - LIST_TOP);
|
|
138
140
|
this.buildRows();
|
|
@@ -160,7 +162,7 @@ export class HistoryScreen extends Screen {
|
|
|
160
162
|
buf.writeText(0, y, (prefix + row).slice(0, w), style);
|
|
161
163
|
}
|
|
162
164
|
if (this.sessions.length === 0) {
|
|
163
|
-
buf.centerText(LIST_TOP + 1, "No sessions
|
|
165
|
+
buf.centerText(LIST_TOP + 1, "No sessions in this directory", { fg: C.dim });
|
|
164
166
|
}
|
|
165
167
|
drawHint(buf, listBottom, "↑/↓ select · Enter load · click to load · Delete delete · right-click back · Esc back");
|
|
166
168
|
}
|
package/dist/screens/settings.js
CHANGED
|
@@ -1,38 +1,108 @@
|
|
|
1
1
|
import { Screen } from "../terminal/screen.js";
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
|
-
import {
|
|
3
|
+
import { CONTEXT_WINDOW_OPTIONS, DEFAULT_CONTEXT_WINDOW, contextWindow, CONFIG_FILE, INTENSITY_LEVELS, getModelsByProvider, loadConfig, loadModels, saveConfig, saveModels, } from "../config.js";
|
|
4
4
|
import { drawHeader } from "../header.js";
|
|
5
5
|
import { centerVertically, drawHint } from "../ui.js";
|
|
6
|
+
import { hashWebPassword } from "../web/auth.js";
|
|
6
7
|
/** The settings that can be changed here, top to bottom. */
|
|
7
|
-
const ROWS = ["intensity", "compress"];
|
|
8
|
+
const ROWS = ["intensity", "compress", "approval_model", "extreme_approval", "sound", "web_password"];
|
|
8
9
|
export class SettingsScreen extends Screen {
|
|
9
10
|
intensityIdx = 2;
|
|
10
|
-
|
|
11
|
+
contextTokens = DEFAULT_CONTEXT_WINDOW;
|
|
12
|
+
contextChanged = false;
|
|
13
|
+
/** 0 = same model as the chat, otherwise index + 1 into `models`. */
|
|
14
|
+
approvalModelIdx = 0;
|
|
15
|
+
/** 0 = off (human only), 1 = on (two independent assessments). */
|
|
16
|
+
extremeIdx = 0;
|
|
17
|
+
/** 0 = silent, 1 = audible cue on turn end and on approval requests. */
|
|
18
|
+
soundIdx = 1;
|
|
19
|
+
models = [];
|
|
11
20
|
rowIdx = 0;
|
|
12
21
|
lastHoverX = -1;
|
|
13
22
|
lastHoverY = -1;
|
|
23
|
+
editingPassword = false;
|
|
24
|
+
passwordDraft = "";
|
|
25
|
+
passwordMessage = "Press Enter to change";
|
|
14
26
|
onActivate() {
|
|
15
27
|
const config = loadConfig();
|
|
16
28
|
const current = String(config.default_intensity ?? "High");
|
|
17
29
|
const i = INTENSITY_LEVELS.indexOf(current);
|
|
18
30
|
this.intensityIdx = i >= 0 ? i : 2;
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
this.
|
|
31
|
+
const state = this.app.state;
|
|
32
|
+
const model = getModelsByProvider()[state.selectedProvider]?.find((m) => m.name === state.selectedModelName);
|
|
33
|
+
this.contextTokens = contextWindow(model?.context_window);
|
|
34
|
+
this.contextChanged = false;
|
|
35
|
+
this.models = Object.values(getModelsByProvider()).flatMap((list) => list.map((m) => m.name));
|
|
36
|
+
const currentApproval = String(config.approval_model ?? "");
|
|
37
|
+
const approvalAt = currentApproval ? this.models.indexOf(currentApproval) : -1;
|
|
38
|
+
this.approvalModelIdx = approvalAt >= 0 ? approvalAt + 1 : 0;
|
|
39
|
+
this.extremeIdx = config.auto_approve_extreme_risk === true ? 1 : 0;
|
|
40
|
+
this.soundIdx = config.sound_alerts === false ? 0 : 1;
|
|
22
41
|
}
|
|
23
42
|
/** Change the value of a row by `delta` steps and persist it immediately. */
|
|
24
43
|
cycle(row, delta) {
|
|
44
|
+
if (row === "web_password") {
|
|
45
|
+
this.editingPassword = true;
|
|
46
|
+
this.passwordDraft = "";
|
|
47
|
+
this.passwordMessage = "Type new password";
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
25
50
|
if (row === "intensity") {
|
|
26
51
|
const n = INTENSITY_LEVELS.length;
|
|
27
52
|
this.intensityIdx = (this.intensityIdx + delta + n) % n;
|
|
28
53
|
}
|
|
54
|
+
else if (row === "approval_model") {
|
|
55
|
+
const n = this.models.length + 1;
|
|
56
|
+
this.approvalModelIdx = (this.approvalModelIdx + delta + n) % n;
|
|
57
|
+
}
|
|
58
|
+
else if (row === "extreme_approval") {
|
|
59
|
+
// A switch, not a list: any nudge flips it.
|
|
60
|
+
this.extremeIdx = this.extremeIdx === 1 ? 0 : 1;
|
|
61
|
+
}
|
|
62
|
+
else if (row === "sound") {
|
|
63
|
+
this.soundIdx = this.soundIdx === 1 ? 0 : 1;
|
|
64
|
+
}
|
|
29
65
|
else {
|
|
30
|
-
const
|
|
31
|
-
|
|
66
|
+
const options = [...new Set([...CONTEXT_WINDOW_OPTIONS, this.contextTokens])].sort((a, b) => a - b);
|
|
67
|
+
const i = options.indexOf(this.contextTokens);
|
|
68
|
+
this.contextTokens = options[(i + delta + options.length) % options.length];
|
|
69
|
+
this.contextChanged = true;
|
|
32
70
|
}
|
|
33
71
|
this.save();
|
|
34
72
|
}
|
|
35
73
|
onKey(key) {
|
|
74
|
+
if (this.editingPassword) {
|
|
75
|
+
if (key.name === "escape") {
|
|
76
|
+
this.editingPassword = false;
|
|
77
|
+
this.passwordDraft = "";
|
|
78
|
+
this.passwordMessage = "Press Enter to change";
|
|
79
|
+
}
|
|
80
|
+
else if (key.name === "enter" || key.name === "return") {
|
|
81
|
+
try {
|
|
82
|
+
const hash = hashWebPassword(this.passwordDraft);
|
|
83
|
+
const config = loadConfig();
|
|
84
|
+
config.web_password_hash = hash;
|
|
85
|
+
saveConfig(config);
|
|
86
|
+
this.editingPassword = false;
|
|
87
|
+
this.passwordDraft = "";
|
|
88
|
+
this.passwordMessage = "Saved permanently";
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
this.passwordMessage = error instanceof Error ? error.message : "Could not save password";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else if (key.name === "backspace") {
|
|
95
|
+
this.passwordDraft = Array.from(this.passwordDraft).slice(0, -1).join("");
|
|
96
|
+
}
|
|
97
|
+
else if (key.ctrl && key.name === "u") {
|
|
98
|
+
this.passwordDraft = "";
|
|
99
|
+
}
|
|
100
|
+
else if (key.char && !key.ctrl && !key.meta) {
|
|
101
|
+
this.passwordDraft = (this.passwordDraft + key.char).slice(0, 256);
|
|
102
|
+
}
|
|
103
|
+
this.app.render();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
36
106
|
if (key.name === "up") {
|
|
37
107
|
this.rowIdx = (this.rowIdx + ROWS.length - 1) % ROWS.length;
|
|
38
108
|
this.app.render();
|
|
@@ -50,7 +120,10 @@ export class SettingsScreen extends Screen {
|
|
|
50
120
|
this.app.render();
|
|
51
121
|
}
|
|
52
122
|
else if (key.name === "enter" || key.name === "return") {
|
|
53
|
-
this.
|
|
123
|
+
if (ROWS[this.rowIdx] === "web_password")
|
|
124
|
+
this.cycle("web_password", 1);
|
|
125
|
+
else
|
|
126
|
+
this.save();
|
|
54
127
|
this.app.render();
|
|
55
128
|
}
|
|
56
129
|
else if (key.name === "escape") {
|
|
@@ -59,6 +132,8 @@ export class SettingsScreen extends Screen {
|
|
|
59
132
|
}
|
|
60
133
|
}
|
|
61
134
|
onMouse(ev) {
|
|
135
|
+
if (this.editingPassword)
|
|
136
|
+
return;
|
|
62
137
|
const layout = this.layout();
|
|
63
138
|
if (!layout)
|
|
64
139
|
return;
|
|
@@ -106,10 +181,38 @@ export class SettingsScreen extends Screen {
|
|
|
106
181
|
}
|
|
107
182
|
}
|
|
108
183
|
}
|
|
184
|
+
onPaste(text) {
|
|
185
|
+
if (!this.editingPassword)
|
|
186
|
+
return;
|
|
187
|
+
this.passwordDraft = (this.passwordDraft + text).slice(0, 256);
|
|
188
|
+
this.app.render();
|
|
189
|
+
}
|
|
190
|
+
onDeactivate() {
|
|
191
|
+
this.passwordDraft = "";
|
|
192
|
+
this.editingPassword = false;
|
|
193
|
+
}
|
|
109
194
|
rowTexts() {
|
|
110
195
|
return [
|
|
111
196
|
{ label: "Default intensity", value: INTENSITY_LEVELS[this.intensityIdx] },
|
|
112
|
-
{ label: "
|
|
197
|
+
{ label: "Model context (80%)", value: `${this.contextTokens.toLocaleString("en-US")} tokens` },
|
|
198
|
+
{
|
|
199
|
+
label: "Approval reviewer model",
|
|
200
|
+
value: this.approvalModelIdx === 0 ? "Same as chat" : this.models[this.approvalModelIdx - 1] ?? "Same as chat",
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
label: "Extreme-risk auto-approval",
|
|
204
|
+
// Names the mechanism: three separate review rounds, not three reviewers
|
|
205
|
+
// sitting in one panel.
|
|
206
|
+
value: this.extremeIdx === 1 ? "On (3 separate rounds)" : "Off (human only)",
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
label: "Sound alerts",
|
|
210
|
+
value: this.soundIdx === 1 ? "On (turn end, approvals)" : "Off",
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
label: "Web password",
|
|
214
|
+
value: this.editingPassword ? "*".repeat(Math.min(this.passwordDraft.length, 24)) || "Type new password" : this.passwordMessage,
|
|
215
|
+
},
|
|
113
216
|
];
|
|
114
217
|
}
|
|
115
218
|
layout() {
|
|
@@ -128,7 +231,19 @@ export class SettingsScreen extends Screen {
|
|
|
128
231
|
save() {
|
|
129
232
|
const config = loadConfig();
|
|
130
233
|
config.default_intensity = INTENSITY_LEVELS[this.intensityIdx];
|
|
131
|
-
|
|
234
|
+
if (this.contextChanged) {
|
|
235
|
+
const data = loadModels();
|
|
236
|
+
const state = this.app.state;
|
|
237
|
+
const model = data.providers.find((p) => p.name === state.selectedProvider)?.models.find((m) => m.name === state.selectedModelName);
|
|
238
|
+
if (model) {
|
|
239
|
+
model.context_window = this.contextTokens;
|
|
240
|
+
saveModels(data);
|
|
241
|
+
}
|
|
242
|
+
this.contextChanged = false;
|
|
243
|
+
}
|
|
244
|
+
config.approval_model = this.approvalModelIdx === 0 ? "" : this.models[this.approvalModelIdx - 1] ?? "";
|
|
245
|
+
config.auto_approve_extreme_risk = this.extremeIdx === 1;
|
|
246
|
+
config.sound_alerts = this.soundIdx === 1;
|
|
132
247
|
saveConfig(config);
|
|
133
248
|
}
|
|
134
249
|
render(buf) {
|
|
@@ -149,7 +264,7 @@ export class SettingsScreen extends Screen {
|
|
|
149
264
|
const selected = i === this.rowIdx;
|
|
150
265
|
const marker = selected ? "▸ " : " ";
|
|
151
266
|
const label = rows[i].label.padEnd(labelW);
|
|
152
|
-
const value = selected ? `◂ ${rows[i].value} ▸` : rows[i].value;
|
|
267
|
+
const value = selected && ROWS[i] !== "web_password" ? `◂ ${rows[i].value} ▸` : rows[i].value;
|
|
153
268
|
const text = marker + label + " " + value;
|
|
154
269
|
if (selected) {
|
|
155
270
|
buf.centerText(y, " " + text + " ", { fg: C.accent, bg: C.highlightBg, bold: true });
|
|
@@ -161,11 +276,13 @@ export class SettingsScreen extends Screen {
|
|
|
161
276
|
}
|
|
162
277
|
y += 1;
|
|
163
278
|
if (y < h) {
|
|
164
|
-
buf.centerText(y, `config: ${CONFIG_FILE}`, { fg: C.dim });
|
|
279
|
+
buf.centerText(y, this.editingPassword ? this.passwordMessage : `config: ${CONFIG_FILE}`, { fg: C.dim });
|
|
165
280
|
y += 1;
|
|
166
281
|
}
|
|
167
282
|
if (y < h) {
|
|
168
|
-
drawHint(buf, y,
|
|
283
|
+
drawHint(buf, y, this.editingPassword
|
|
284
|
+
? "Type or paste password · Enter save · Esc cancel"
|
|
285
|
+
: "↑/↓ select · ←/→ change · Enter apply · right-click back · Esc back");
|
|
169
286
|
}
|
|
170
287
|
}
|
|
171
288
|
}
|
package/dist/screens/start.js
CHANGED
|
@@ -1,21 +1,35 @@
|
|
|
1
1
|
import { Screen } from "../terminal/screen.js";
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
|
-
import { loadLogo, loadVersion } from "../config.js";
|
|
3
|
+
import { loadLogo, loadVersion, loadConfig, saveConfig, normalizeApprovalMode } from "../config.js";
|
|
4
4
|
import { drawHeader } from "../header.js";
|
|
5
|
-
import { centerVertically, computeInputWindow, cursorFromColumn, drawHint, drawInputBox, drawTabButton, inRect, } from "../ui.js";
|
|
5
|
+
import { centerVertically, computeInputWindow, cursorFromColumn, drawHint, drawInputBox, drawTabButton, inRect, approvalToggle, drawApprovalToggle, } from "../ui.js";
|
|
6
6
|
const TABS = ["Model", "History", "Settings"];
|
|
7
7
|
const LOGO = loadLogo();
|
|
8
|
-
const FOCUS_COUNT = TABS.length +
|
|
8
|
+
const FOCUS_COUNT = TABS.length + 2; // input box + 3 tabs + approval mode
|
|
9
9
|
const INPUT_H = 3;
|
|
10
10
|
const TAB_H = 3;
|
|
11
11
|
function isLowSurrogate(code) {
|
|
12
12
|
return code >= 0xdc00 && code <= 0xdfff;
|
|
13
13
|
}
|
|
14
14
|
export class StartScreen extends Screen {
|
|
15
|
+
get approvalMode() {
|
|
16
|
+
return normalizeApprovalMode(loadConfig().approval_mode);
|
|
17
|
+
}
|
|
18
|
+
toggleApproval() {
|
|
19
|
+
const config = loadConfig();
|
|
20
|
+
config.approval_mode = this.approvalMode === "auto" ? "request" : "auto";
|
|
21
|
+
saveConfig(config);
|
|
22
|
+
this.app.render();
|
|
23
|
+
}
|
|
24
|
+
logoLines() {
|
|
25
|
+
const lines = LOGO.replace(/\n+$/, "").split("\n");
|
|
26
|
+
const count = Math.max(0, Math.min(lines.length, this.app.height - 13));
|
|
27
|
+
return count ? lines.slice(-count) : [];
|
|
28
|
+
}
|
|
15
29
|
inputValue = "";
|
|
16
30
|
cursor = 0;
|
|
17
|
-
focusIdx = 0; // 0 = input box, 1..3 = tabs
|
|
18
|
-
hoverIdx = -1; // -1 = nothing,
|
|
31
|
+
focusIdx = 0; // 0 = input box, 1..3 = tabs, 4 = approval mode
|
|
32
|
+
hoverIdx = -1; // -1 = nothing, otherwise the control index
|
|
19
33
|
lastHoverX = -1;
|
|
20
34
|
lastHoverY = -1;
|
|
21
35
|
showsCursor() {
|
|
@@ -34,6 +48,10 @@ export class StartScreen extends Screen {
|
|
|
34
48
|
this.app.render();
|
|
35
49
|
}
|
|
36
50
|
onKey(key) {
|
|
51
|
+
if (key.name === "tab") {
|
|
52
|
+
this.move(key.shift ? -1 : 1);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
37
55
|
// When the input box is focused and holds text, left/right move the text
|
|
38
56
|
// cursor instead of switching between the input box and the tabs. When the
|
|
39
57
|
// input box is empty (or another control is focused), left/right switch.
|
|
@@ -61,6 +79,9 @@ export class StartScreen extends Screen {
|
|
|
61
79
|
if (text)
|
|
62
80
|
this.app.startNewChat(text);
|
|
63
81
|
}
|
|
82
|
+
else if (this.focusIdx === FOCUS_COUNT - 1) {
|
|
83
|
+
this.toggleApproval();
|
|
84
|
+
}
|
|
64
85
|
else {
|
|
65
86
|
this.enterTab();
|
|
66
87
|
}
|
|
@@ -117,7 +138,10 @@ export class StartScreen extends Screen {
|
|
|
117
138
|
this.lastHoverY = ev.y;
|
|
118
139
|
const layout = this.layout();
|
|
119
140
|
let hover = -1;
|
|
120
|
-
if (
|
|
141
|
+
if (inRect(ev.x, ev.y, approvalToggle(this.app.width, this.app.height - 4, this.approvalMode))) {
|
|
142
|
+
hover = FOCUS_COUNT - 1;
|
|
143
|
+
}
|
|
144
|
+
else if (layout && inRect(ev.x, ev.y, { x: layout.inputX, y: layout.inputY, w: layout.inputW, h: INPUT_H })) {
|
|
121
145
|
hover = 0;
|
|
122
146
|
}
|
|
123
147
|
else if (layout && ev.y >= layout.tabY && ev.y < layout.tabY + TAB_H) {
|
|
@@ -140,6 +164,11 @@ export class StartScreen extends Screen {
|
|
|
140
164
|
return;
|
|
141
165
|
if (ev.button === 2)
|
|
142
166
|
return; // right click: no-op on start screen
|
|
167
|
+
if (ev.y >= 1 && inRect(ev.x, ev.y, approvalToggle(this.app.width, this.app.height - 4, this.approvalMode))) {
|
|
168
|
+
this.focusIdx = FOCUS_COUNT - 1;
|
|
169
|
+
this.toggleApproval();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
143
172
|
if (ev.y === 0) {
|
|
144
173
|
this.app.openModelConfig();
|
|
145
174
|
return;
|
|
@@ -180,6 +209,8 @@ export class StartScreen extends Screen {
|
|
|
180
209
|
for (let y = inputY; y <= tabY + TAB_H; y++)
|
|
181
210
|
if (y >= 0 && y < h)
|
|
182
211
|
rows.push(y);
|
|
212
|
+
if (h - 4 >= 1)
|
|
213
|
+
rows.push(h - 4);
|
|
183
214
|
return rows;
|
|
184
215
|
}
|
|
185
216
|
layout() {
|
|
@@ -187,10 +218,11 @@ export class StartScreen extends Screen {
|
|
|
187
218
|
const h = this.app.height;
|
|
188
219
|
const inputW = Math.min(78, w - 4);
|
|
189
220
|
const inputX = Math.floor((w - inputW) / 2);
|
|
190
|
-
const logoLines =
|
|
221
|
+
const logoLines = this.logoLines();
|
|
191
222
|
const gap = 1;
|
|
192
|
-
const
|
|
193
|
-
const
|
|
223
|
+
const logoGap = logoLines.length ? 1 : 0;
|
|
224
|
+
const contentH = logoLines.length + logoGap + INPUT_H + gap + TAB_H;
|
|
225
|
+
const inputY = centerVertically(h - 4, contentH) + logoLines.length + logoGap;
|
|
194
226
|
const tabY = inputY + INPUT_H + gap;
|
|
195
227
|
const tabGap = 2;
|
|
196
228
|
const tabInner = inputW - tabGap * (TABS.length - 1);
|
|
@@ -242,18 +274,19 @@ export class StartScreen extends Screen {
|
|
|
242
274
|
const h = buf.height;
|
|
243
275
|
const inputW = Math.min(78, w - 4);
|
|
244
276
|
const inputX = Math.floor((w - inputW) / 2);
|
|
245
|
-
const logoLines =
|
|
277
|
+
const logoLines = this.logoLines();
|
|
246
278
|
const logoMax = logoLines.reduce((m, l) => Math.max(m, l.length), 0);
|
|
247
279
|
const logoX = Math.max(0, Math.floor((w - logoMax) / 2));
|
|
248
280
|
const gap = 1;
|
|
249
|
-
const
|
|
250
|
-
const
|
|
281
|
+
const logoGap = logoLines.length ? 1 : 0;
|
|
282
|
+
const contentH = logoLines.length + logoGap + INPUT_H + gap + TAB_H;
|
|
283
|
+
const top = centerVertically(h - 4, contentH);
|
|
251
284
|
let y = top;
|
|
252
285
|
for (const line of logoLines) {
|
|
253
286
|
buf.writeText(logoX, y, line, { fg: C.accent, bold: true });
|
|
254
287
|
y++;
|
|
255
288
|
}
|
|
256
|
-
y +=
|
|
289
|
+
y += logoGap;
|
|
257
290
|
drawInputBox(buf, inputX, y, inputW, this.inputValue, this.cursor, {
|
|
258
291
|
active: this.focusIdx === 0,
|
|
259
292
|
cursorOn: this.app.state.cursorOn,
|
|
@@ -277,10 +310,10 @@ export class StartScreen extends Screen {
|
|
|
277
310
|
const version = loadVersion();
|
|
278
311
|
const versionY = h - 2;
|
|
279
312
|
const showVersion = version.length > 0 && versionY >= 1;
|
|
280
|
-
//
|
|
281
|
-
// or below the reserved version row so the two never collide.
|
|
313
|
+
// Reserve the button row and the blank row between it and the version.
|
|
282
314
|
const hintY = y + TAB_H + 1;
|
|
283
|
-
|
|
315
|
+
const approvalY = h - 4;
|
|
316
|
+
if (hintY < approvalY) {
|
|
284
317
|
const nav = this.focusIdx === 0 && this.inputValue.length > 0
|
|
285
318
|
? "←/→ move cursor · ↑/↓ switch"
|
|
286
319
|
: "←/→ / ↑/↓ switch";
|
|
@@ -289,5 +322,6 @@ export class StartScreen extends Screen {
|
|
|
289
322
|
if (showVersion) {
|
|
290
323
|
buf.centerText(versionY, `v${version}`, { fg: C.dim });
|
|
291
324
|
}
|
|
325
|
+
drawApprovalToggle(buf, approvalY, this.approvalMode, this.focusIdx === FOCUS_COUNT - 1 || this.hoverIdx === FOCUS_COUNT - 1);
|
|
292
326
|
}
|
|
293
327
|
}
|
package/dist/selection.js
CHANGED
|
@@ -6,6 +6,12 @@ export function normalizeSelection(sel) {
|
|
|
6
6
|
return { start: a, end: b };
|
|
7
7
|
return { start: b, end: a };
|
|
8
8
|
}
|
|
9
|
+
/** Order the two ends of a line-space selection into reading order. */
|
|
10
|
+
export function orderLinePoints(a, b) {
|
|
11
|
+
if (a.line < b.line || (a.line === b.line && a.x <= b.x))
|
|
12
|
+
return { start: a, end: b };
|
|
13
|
+
return { start: b, end: a };
|
|
14
|
+
}
|
|
9
15
|
/**
|
|
10
16
|
* Index of the last cell on a row that holds real content, or -1 when the row
|
|
11
17
|
* has none. UI chrome (a scrollbar) and blank cells are ignored, so a highlight
|
|
@@ -87,3 +93,57 @@ export function selectionText(buf, sel) {
|
|
|
87
93
|
lines.pop();
|
|
88
94
|
return lines.join("\n");
|
|
89
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* One buffer row as column-indexed text: `cells[x]` is what column `x` holds,
|
|
98
|
+
* or `""` for a cell that never copies (UI chrome, and the tail of a
|
|
99
|
+
* double-width glyph).
|
|
100
|
+
*
|
|
101
|
+
* Keeping the column index is the point: it lets a row be sliced by column
|
|
102
|
+
* later, after the frame it came from is long gone. A drag that scrolls the log
|
|
103
|
+
* needs exactly that, because the rows it started on leave the screen while the
|
|
104
|
+
* selection keeps growing.
|
|
105
|
+
*/
|
|
106
|
+
export function rowCells(buf, y) {
|
|
107
|
+
const row = buf.cells[y];
|
|
108
|
+
if (!row)
|
|
109
|
+
return [];
|
|
110
|
+
const cells = new Array(row.length);
|
|
111
|
+
for (let x = 0; x < row.length; x++) {
|
|
112
|
+
const cell = row[x];
|
|
113
|
+
cells[x] = !cell || cell.chrome || cell.ch === "" ? "" : cell.ch;
|
|
114
|
+
}
|
|
115
|
+
return cells;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Extract the text of a selection expressed in content lines (`rowSelectionText`
|
|
119
|
+
* is to `selectionText` what `LinePoint` is to `Point`).
|
|
120
|
+
*
|
|
121
|
+
* `getRow` supplies the cells of a line — from the current frame while the line
|
|
122
|
+
* is on screen, from the snapshot taken during the drag once it has scrolled
|
|
123
|
+
* away, or `null` for a line that was never captured. Trailing blanks and edge
|
|
124
|
+
* blank lines are trimmed exactly like `selectionText`, so both paths produce
|
|
125
|
+
* the same text for a selection that fits on one screen.
|
|
126
|
+
*/
|
|
127
|
+
export function rowSelectionText(getRow, start, end) {
|
|
128
|
+
const lines = [];
|
|
129
|
+
for (let line = start.line; line <= end.line; line++) {
|
|
130
|
+
const row = getRow(line);
|
|
131
|
+
if (!row) {
|
|
132
|
+
// Never captured (a line that scrolled past without being drawn). Keep
|
|
133
|
+
// the line count honest instead of glueing its neighbours together.
|
|
134
|
+
lines.push("");
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const x0 = line === start.line ? Math.max(0, start.x) : 0;
|
|
138
|
+
const x1 = line === end.line ? Math.min(row.length - 1, end.x) : row.length - 1;
|
|
139
|
+
let text = "";
|
|
140
|
+
for (let x = x0; x <= x1; x++)
|
|
141
|
+
text += row[x] ?? "";
|
|
142
|
+
lines.push(text.replace(/[ \t]+$/, ""));
|
|
143
|
+
}
|
|
144
|
+
while (lines.length > 0 && lines[0] === "")
|
|
145
|
+
lines.shift();
|
|
146
|
+
while (lines.length > 0 && lines[lines.length - 1] === "")
|
|
147
|
+
lines.pop();
|
|
148
|
+
return lines.join("\n");
|
|
149
|
+
}
|
package/dist/state.js
CHANGED
package/dist/terminal/input.js
CHANGED
|
@@ -54,9 +54,20 @@ export class InputReader {
|
|
|
54
54
|
stdin.removeListener("data", this.dataHandler);
|
|
55
55
|
if (this.escTimer)
|
|
56
56
|
clearTimeout(this.escTimer);
|
|
57
|
-
|
|
58
|
-
stdin.
|
|
59
|
-
|
|
57
|
+
try {
|
|
58
|
+
if (stdin.isTTY)
|
|
59
|
+
stdin.setRawMode(false);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// The console already went away; leaving raw mode is best-effort here and
|
|
63
|
+
// failing must not abort the rest of the shutdown.
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
stdin.pause();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Same as above.
|
|
70
|
+
}
|
|
60
71
|
}
|
|
61
72
|
onData(chunk) {
|
|
62
73
|
this.pending += this.decoder.decode(chunk, { stream: true });
|
|
@@ -183,6 +194,24 @@ export class InputReader {
|
|
|
183
194
|
finishCsi() {
|
|
184
195
|
const s = this.csiBuf;
|
|
185
196
|
const final = s[s.length - 1];
|
|
197
|
+
// CSI-u (kitty) and xterm modifyOtherKeys preserve Ctrl+Shift separately.
|
|
198
|
+
const extended = final === "u"
|
|
199
|
+
? /^(\d+)(?::[\d:]*)?(?:;(\d+)(?::(\d+))?)?(?:;[\d:]*)?u$/.exec(s)
|
|
200
|
+
: /^27;(\d+);(\d+)~$/.exec(s);
|
|
201
|
+
if (extended) {
|
|
202
|
+
const code = Number(extended[final === "u" ? 1 : 2]);
|
|
203
|
+
const modifiers = Number(extended[final === "u" ? 2 : 1] ?? 1) - 1;
|
|
204
|
+
// Ignore key-up reports: releasing C must not count as another press.
|
|
205
|
+
if (final === "u" && extended[3] === "3")
|
|
206
|
+
return;
|
|
207
|
+
if (code < 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff))
|
|
208
|
+
return;
|
|
209
|
+
const char = String.fromCodePoint(code);
|
|
210
|
+
const special = { 13: "return", 9: "tab", 27: "escape", 127: "backspace" };
|
|
211
|
+
const ctrl = (modifiers & 4) !== 0;
|
|
212
|
+
this.emitKey(special[code] ?? (ctrl ? char.toLowerCase() : char), ctrl, (modifiers & 2) !== 0, (modifiers & 1) !== 0, char, "\x1b[" + s);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
186
215
|
if (final === "A")
|
|
187
216
|
this.emitKey("up", false, false, false, "\x1b[A");
|
|
188
217
|
else if (final === "B")
|
|
@@ -261,7 +290,7 @@ export class InputReader {
|
|
|
261
290
|
this.emitMouse({ type: "click", x, y, button });
|
|
262
291
|
}
|
|
263
292
|
}
|
|
264
|
-
emitKey(name, ctrl, meta, shift, char) {
|
|
293
|
+
emitKey(name, ctrl, meta, shift, char, sequence = char) {
|
|
265
294
|
if (!this.handler)
|
|
266
295
|
return;
|
|
267
296
|
this.handler({
|
|
@@ -272,7 +301,7 @@ export class InputReader {
|
|
|
272
301
|
meta,
|
|
273
302
|
shift,
|
|
274
303
|
char,
|
|
275
|
-
sequence
|
|
304
|
+
sequence,
|
|
276
305
|
},
|
|
277
306
|
});
|
|
278
307
|
}
|