@xingjisoft/neutrino 0.1.3 → 0.1.5
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 +398 -137
- package/assets/prompt.txt +4 -0
- package/dist/agents.js +114 -15
- package/dist/app.js +105 -4
- package/dist/bin.js +177 -5
- package/dist/colors.js +2 -0
- package/dist/compact.js +87 -0
- package/dist/config.js +121 -13
- package/dist/cwd.js +54 -0
- package/dist/header.js +8 -0
- package/dist/llm/approval.js +543 -0
- package/dist/llm/client.js +323 -84
- package/dist/llm/risk.js +377 -0
- package/dist/llm/tools.js +37 -21
- package/dist/persistence.js +51 -0
- package/dist/sanitize.js +61 -0
- package/dist/screens/agentHistory.js +7 -4
- package/dist/screens/chat.js +749 -44
- package/dist/screens/history.js +9 -7
- package/dist/screens/settings.js +201 -45
- package/dist/screens/start.js +64 -16
- package/dist/selection.js +25 -11
- package/dist/state.js +1 -0
- package/dist/terminal/buffer.js +24 -3
- package/dist/terminal/input.js +14 -3
- package/dist/terminal/renderer.js +72 -9
- package/dist/terminal/restore.js +147 -0
- package/dist/terminal/screen.js +15 -0
- package/dist/ui.js +19 -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 +208 -0
- package/web/app.js +999 -0
- package/web/index.html +87 -0
- package/web/settings.js +193 -0
- package/web/style.css +941 -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,29 +1,130 @@
|
|
|
1
1
|
import { Screen } from "../terminal/screen.js";
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
|
-
import { INTENSITY_LEVELS,
|
|
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";
|
|
7
|
+
/** The settings that can be changed here, top to bottom. */
|
|
8
|
+
const ROWS = ["intensity", "compress", "approval_model", "extreme_approval", "sound", "web_password"];
|
|
6
9
|
export class SettingsScreen extends Screen {
|
|
7
10
|
intensityIdx = 2;
|
|
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 = [];
|
|
20
|
+
rowIdx = 0;
|
|
8
21
|
lastHoverX = -1;
|
|
9
22
|
lastHoverY = -1;
|
|
23
|
+
editingPassword = false;
|
|
24
|
+
passwordDraft = "";
|
|
25
|
+
passwordMessage = "Press Enter to change";
|
|
10
26
|
onActivate() {
|
|
11
27
|
const config = loadConfig();
|
|
12
28
|
const current = String(config.default_intensity ?? "High");
|
|
13
29
|
const i = INTENSITY_LEVELS.indexOf(current);
|
|
14
30
|
this.intensityIdx = i >= 0 ? i : 2;
|
|
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;
|
|
41
|
+
}
|
|
42
|
+
/** Change the value of a row by `delta` steps and persist it immediately. */
|
|
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
|
+
}
|
|
50
|
+
if (row === "intensity") {
|
|
51
|
+
const n = INTENSITY_LEVELS.length;
|
|
52
|
+
this.intensityIdx = (this.intensityIdx + delta + n) % n;
|
|
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
|
+
}
|
|
65
|
+
else {
|
|
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;
|
|
70
|
+
}
|
|
71
|
+
this.save();
|
|
15
72
|
}
|
|
16
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
|
+
}
|
|
17
106
|
if (key.name === "up") {
|
|
18
|
-
this.
|
|
107
|
+
this.rowIdx = (this.rowIdx + ROWS.length - 1) % ROWS.length;
|
|
19
108
|
this.app.render();
|
|
20
109
|
}
|
|
21
110
|
else if (key.name === "down") {
|
|
22
|
-
this.
|
|
111
|
+
this.rowIdx = (this.rowIdx + 1) % ROWS.length;
|
|
112
|
+
this.app.render();
|
|
113
|
+
}
|
|
114
|
+
else if (key.name === "left") {
|
|
115
|
+
this.cycle(ROWS[this.rowIdx], -1);
|
|
116
|
+
this.app.render();
|
|
117
|
+
}
|
|
118
|
+
else if (key.name === "right") {
|
|
119
|
+
this.cycle(ROWS[this.rowIdx], 1);
|
|
23
120
|
this.app.render();
|
|
24
121
|
}
|
|
25
122
|
else if (key.name === "enter" || key.name === "return") {
|
|
26
|
-
this.
|
|
123
|
+
if (ROWS[this.rowIdx] === "web_password")
|
|
124
|
+
this.cycle("web_password", 1);
|
|
125
|
+
else
|
|
126
|
+
this.save();
|
|
127
|
+
this.app.render();
|
|
27
128
|
}
|
|
28
129
|
else if (key.name === "escape") {
|
|
29
130
|
this.save();
|
|
@@ -31,6 +132,8 @@ export class SettingsScreen extends Screen {
|
|
|
31
132
|
}
|
|
32
133
|
}
|
|
33
134
|
onMouse(ev) {
|
|
135
|
+
if (this.editingPassword)
|
|
136
|
+
return;
|
|
34
137
|
const layout = this.layout();
|
|
35
138
|
if (!layout)
|
|
36
139
|
return;
|
|
@@ -39,9 +142,9 @@ export class SettingsScreen extends Screen {
|
|
|
39
142
|
return;
|
|
40
143
|
this.lastHoverX = ev.x;
|
|
41
144
|
this.lastHoverY = ev.y;
|
|
42
|
-
for (let i = 0; i < layout.
|
|
43
|
-
if (ev.y === layout.
|
|
44
|
-
this.
|
|
145
|
+
for (let i = 0; i < layout.rowYs.length; i++) {
|
|
146
|
+
if (ev.y === layout.rowYs[i] && i !== this.rowIdx) {
|
|
147
|
+
this.rowIdx = i;
|
|
45
148
|
this.app.render();
|
|
46
149
|
return;
|
|
47
150
|
}
|
|
@@ -49,8 +152,7 @@ export class SettingsScreen extends Screen {
|
|
|
49
152
|
return;
|
|
50
153
|
}
|
|
51
154
|
if (ev.type === "wheel") {
|
|
52
|
-
this.
|
|
53
|
-
(this.intensityIdx + (ev.dir === -1 ? -1 : 1) + INTENSITY_LEVELS.length) % INTENSITY_LEVELS.length;
|
|
155
|
+
this.cycle(ROWS[this.rowIdx], ev.dir === -1 ? -1 : 1);
|
|
54
156
|
this.app.render();
|
|
55
157
|
return;
|
|
56
158
|
}
|
|
@@ -60,10 +162,15 @@ export class SettingsScreen extends Screen {
|
|
|
60
162
|
this.app.popScreen();
|
|
61
163
|
return;
|
|
62
164
|
}
|
|
63
|
-
for (let i = 0; i < layout.
|
|
64
|
-
if (ev.y === layout.
|
|
65
|
-
this.
|
|
66
|
-
|
|
165
|
+
for (let i = 0; i < layout.rowYs.length; i++) {
|
|
166
|
+
if (ev.y === layout.rowYs[i]) {
|
|
167
|
+
if (this.rowIdx === i) {
|
|
168
|
+
// Clicking the already-selected row advances its value.
|
|
169
|
+
this.cycle(ROWS[i], 1);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
this.rowIdx = i;
|
|
173
|
+
}
|
|
67
174
|
this.app.render();
|
|
68
175
|
return;
|
|
69
176
|
}
|
|
@@ -74,59 +181,108 @@ export class SettingsScreen extends Screen {
|
|
|
74
181
|
}
|
|
75
182
|
}
|
|
76
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
|
+
}
|
|
194
|
+
rowTexts() {
|
|
195
|
+
return [
|
|
196
|
+
{ label: "Default intensity", value: INTENSITY_LEVELS[this.intensityIdx] },
|
|
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
|
+
},
|
|
216
|
+
];
|
|
217
|
+
}
|
|
77
218
|
layout() {
|
|
78
219
|
const h = this.app.height;
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
`Default intensity:`,
|
|
84
|
-
];
|
|
85
|
-
const total = lines.length + INTENSITY_LEVELS.length + 2;
|
|
220
|
+
const rows = this.rowTexts();
|
|
221
|
+
const labelW = Math.max(...rows.map((r) => r.label.length));
|
|
222
|
+
// title, blank, model, blank, option rows, blank, hint
|
|
223
|
+
const total = 6 + rows.length;
|
|
86
224
|
const top = centerVertically(h, total);
|
|
87
|
-
const
|
|
88
|
-
const hintY = top +
|
|
225
|
+
const rowYs = rows.map((_, i) => top + 4 + i);
|
|
226
|
+
const hintY = top + 5 + rows.length;
|
|
89
227
|
if (hintY > h - 1)
|
|
90
228
|
return null;
|
|
91
|
-
return {
|
|
229
|
+
return { rowYs, hintY, labelW };
|
|
92
230
|
}
|
|
93
231
|
save() {
|
|
94
232
|
const config = loadConfig();
|
|
95
233
|
config.default_intensity = INTENSITY_LEVELS[this.intensityIdx];
|
|
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;
|
|
96
247
|
saveConfig(config);
|
|
97
248
|
}
|
|
98
249
|
render(buf) {
|
|
99
250
|
drawHeader(buf, this.app.state);
|
|
100
251
|
const h = buf.height;
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
`Model: ${this.app.state.selectedProvider}/${this.app.state.selectedModelName}`,
|
|
105
|
-
`Default intensity:`,
|
|
106
|
-
];
|
|
107
|
-
const content = INTENSITY_LEVELS.map((l, i) => ({
|
|
108
|
-
text: l,
|
|
109
|
-
selected: i === this.intensityIdx,
|
|
110
|
-
}));
|
|
111
|
-
const total = lines.length + content.length + 2;
|
|
252
|
+
const rows = this.rowTexts();
|
|
253
|
+
const labelW = Math.max(...rows.map((r) => r.label.length));
|
|
254
|
+
const total = 6 + rows.length;
|
|
112
255
|
const top = centerVertically(h, total);
|
|
113
256
|
let y = top;
|
|
114
|
-
buf.centerText(y,
|
|
257
|
+
buf.centerText(y, "Settings", { fg: C.accent, bold: true });
|
|
115
258
|
y += 2;
|
|
116
|
-
buf.centerText(y,
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
y +=
|
|
120
|
-
for (
|
|
121
|
-
const
|
|
122
|
-
|
|
259
|
+
buf.centerText(y, `Model: ${this.app.state.selectedProvider}/${this.app.state.selectedModelName}`, {
|
|
260
|
+
fg: C.assistant,
|
|
261
|
+
});
|
|
262
|
+
y += 2;
|
|
263
|
+
for (let i = 0; i < rows.length; i++) {
|
|
264
|
+
const selected = i === this.rowIdx;
|
|
265
|
+
const marker = selected ? "▸ " : " ";
|
|
266
|
+
const label = rows[i].label.padEnd(labelW);
|
|
267
|
+
const value = selected && ROWS[i] !== "web_password" ? `◂ ${rows[i].value} ▸` : rows[i].value;
|
|
268
|
+
const text = marker + label + " " + value;
|
|
269
|
+
if (selected) {
|
|
270
|
+
buf.centerText(y, " " + text + " ", { fg: C.accent, bg: C.highlightBg, bold: true });
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
buf.centerText(y, text, { fg: C.assistant });
|
|
274
|
+
}
|
|
123
275
|
y += 1;
|
|
124
276
|
}
|
|
125
277
|
y += 1;
|
|
126
|
-
buf.centerText(y, `model.json: ${MODEL_FILE}`, { fg: C.dim });
|
|
127
|
-
y += 1;
|
|
128
278
|
if (y < h) {
|
|
129
|
-
|
|
279
|
+
buf.centerText(y, this.editingPassword ? this.passwordMessage : `config: ${CONFIG_FILE}`, { fg: C.dim });
|
|
280
|
+
y += 1;
|
|
281
|
+
}
|
|
282
|
+
if (y < h) {
|
|
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");
|
|
130
286
|
}
|
|
131
287
|
}
|
|
132
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;
|
|
@@ -168,15 +197,32 @@ export class StartScreen extends Screen {
|
|
|
168
197
|
return;
|
|
169
198
|
}
|
|
170
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* The message box, the tabs below it and the hint row are persistent chrome;
|
|
202
|
+
* repainting them every frame keeps them self-healing against terminal-side
|
|
203
|
+
* drift (see Screen.alwaysRepaintRows).
|
|
204
|
+
*/
|
|
205
|
+
alwaysRepaintRows() {
|
|
206
|
+
const { inputY, tabY } = this.layout();
|
|
207
|
+
const h = this.app.height;
|
|
208
|
+
const rows = [];
|
|
209
|
+
for (let y = inputY; y <= tabY + TAB_H; y++)
|
|
210
|
+
if (y >= 0 && y < h)
|
|
211
|
+
rows.push(y);
|
|
212
|
+
if (h - 4 >= 1)
|
|
213
|
+
rows.push(h - 4);
|
|
214
|
+
return rows;
|
|
215
|
+
}
|
|
171
216
|
layout() {
|
|
172
217
|
const w = this.app.width;
|
|
173
218
|
const h = this.app.height;
|
|
174
219
|
const inputW = Math.min(78, w - 4);
|
|
175
220
|
const inputX = Math.floor((w - inputW) / 2);
|
|
176
|
-
const logoLines =
|
|
221
|
+
const logoLines = this.logoLines();
|
|
177
222
|
const gap = 1;
|
|
178
|
-
const
|
|
179
|
-
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;
|
|
180
226
|
const tabY = inputY + INPUT_H + gap;
|
|
181
227
|
const tabGap = 2;
|
|
182
228
|
const tabInner = inputW - tabGap * (TABS.length - 1);
|
|
@@ -228,18 +274,19 @@ export class StartScreen extends Screen {
|
|
|
228
274
|
const h = buf.height;
|
|
229
275
|
const inputW = Math.min(78, w - 4);
|
|
230
276
|
const inputX = Math.floor((w - inputW) / 2);
|
|
231
|
-
const logoLines =
|
|
277
|
+
const logoLines = this.logoLines();
|
|
232
278
|
const logoMax = logoLines.reduce((m, l) => Math.max(m, l.length), 0);
|
|
233
279
|
const logoX = Math.max(0, Math.floor((w - logoMax) / 2));
|
|
234
280
|
const gap = 1;
|
|
235
|
-
const
|
|
236
|
-
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);
|
|
237
284
|
let y = top;
|
|
238
285
|
for (const line of logoLines) {
|
|
239
286
|
buf.writeText(logoX, y, line, { fg: C.accent, bold: true });
|
|
240
287
|
y++;
|
|
241
288
|
}
|
|
242
|
-
y +=
|
|
289
|
+
y += logoGap;
|
|
243
290
|
drawInputBox(buf, inputX, y, inputW, this.inputValue, this.cursor, {
|
|
244
291
|
active: this.focusIdx === 0,
|
|
245
292
|
cursorOn: this.app.state.cursorOn,
|
|
@@ -263,10 +310,10 @@ export class StartScreen extends Screen {
|
|
|
263
310
|
const version = loadVersion();
|
|
264
311
|
const versionY = h - 2;
|
|
265
312
|
const showVersion = version.length > 0 && versionY >= 1;
|
|
266
|
-
//
|
|
267
|
-
// 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.
|
|
268
314
|
const hintY = y + TAB_H + 1;
|
|
269
|
-
|
|
315
|
+
const approvalY = h - 4;
|
|
316
|
+
if (hintY < approvalY) {
|
|
270
317
|
const nav = this.focusIdx === 0 && this.inputValue.length > 0
|
|
271
318
|
? "←/→ move cursor · ↑/↓ switch"
|
|
272
319
|
: "←/→ / ↑/↓ switch";
|
|
@@ -275,5 +322,6 @@ export class StartScreen extends Screen {
|
|
|
275
322
|
if (showVersion) {
|
|
276
323
|
buf.centerText(versionY, `v${version}`, { fg: C.dim });
|
|
277
324
|
}
|
|
325
|
+
drawApprovalToggle(buf, approvalY, this.approvalMode, this.focusIdx === FOCUS_COUNT - 1 || this.hoverIdx === FOCUS_COUNT - 1);
|
|
278
326
|
}
|
|
279
327
|
}
|
package/dist/selection.js
CHANGED
|
@@ -6,11 +6,16 @@ export function normalizeSelection(sel) {
|
|
|
6
6
|
return { start: a, end: b };
|
|
7
7
|
return { start: b, end: a };
|
|
8
8
|
}
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Index of the last cell on a row that holds real content, or -1 when the row
|
|
11
|
+
* has none. UI chrome (a scrollbar) and blank cells are ignored, so a highlight
|
|
12
|
+
* never spills over the scrollbar column.
|
|
13
|
+
*/
|
|
10
14
|
function lastContentCol(buf, y) {
|
|
11
15
|
const row = buf.cells[y];
|
|
12
16
|
for (let x = row.length - 1; x >= 0; x--) {
|
|
13
|
-
|
|
17
|
+
const cell = row[x];
|
|
18
|
+
if (!cell.chrome && cell.ch !== " ")
|
|
14
19
|
return x;
|
|
15
20
|
}
|
|
16
21
|
return -1;
|
|
@@ -34,8 +39,8 @@ export function applySelection(buf, sel) {
|
|
|
34
39
|
continue;
|
|
35
40
|
for (let x = x0; x <= x1; x++) {
|
|
36
41
|
const cell = buf.cells[y][x];
|
|
37
|
-
if (!cell)
|
|
38
|
-
continue;
|
|
42
|
+
if (!cell || cell.chrome)
|
|
43
|
+
continue; // never highlight the scrollbar
|
|
39
44
|
cell.style = {
|
|
40
45
|
fg: C.white,
|
|
41
46
|
bg: C.selection,
|
|
@@ -46,9 +51,18 @@ export function applySelection(buf, sel) {
|
|
|
46
51
|
}
|
|
47
52
|
}
|
|
48
53
|
/**
|
|
49
|
-
* Extract the plain text covered by a selection.
|
|
50
|
-
*
|
|
51
|
-
*
|
|
54
|
+
* Extract the plain text covered by a selection.
|
|
55
|
+
*
|
|
56
|
+
* Three kinds of cell are skipped so the result is clean, pasteable text:
|
|
57
|
+
* - UI chrome (the scrollbar), which is never content;
|
|
58
|
+
* - wide-character continuation cells (`""`), which would duplicate a CJK
|
|
59
|
+
* glyph;
|
|
60
|
+
* - trailing blanks, trimmed per line (shell-style).
|
|
61
|
+
*
|
|
62
|
+
* Intermediate lines are copied out to the buffer's full width and only then
|
|
63
|
+
* trimmed, which keeps indentation intact while dropping the padding on the
|
|
64
|
+
* right. Skipping the scrollbar *before* trimming is what lets that trim
|
|
65
|
+
* actually reach the end of the text instead of stopping at the bar.
|
|
52
66
|
*/
|
|
53
67
|
export function selectionText(buf, sel) {
|
|
54
68
|
const { start, end } = normalizeSelection(sel);
|
|
@@ -57,12 +71,12 @@ export function selectionText(buf, sel) {
|
|
|
57
71
|
const lines = [];
|
|
58
72
|
for (let y = y0; y <= y1; y++) {
|
|
59
73
|
const x0 = y === start.y ? Math.max(0, start.x) : 0;
|
|
60
|
-
|
|
74
|
+
const x1 = y === end.y ? Math.min(buf.width - 1, end.x) : buf.width - 1;
|
|
61
75
|
let line = "";
|
|
62
|
-
for (let x = x0; x <=
|
|
76
|
+
for (let x = x0; x <= x1; x++) {
|
|
63
77
|
const cell = buf.cells[y][x];
|
|
64
|
-
if (!cell || cell.ch === "")
|
|
65
|
-
continue;
|
|
78
|
+
if (!cell || cell.chrome || cell.ch === "")
|
|
79
|
+
continue;
|
|
66
80
|
line += cell.ch;
|
|
67
81
|
}
|
|
68
82
|
lines.push(line.replace(/[ \t]+$/, ""));
|