ninja-terminals 2.4.0 → 2.4.1
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/CLAUDE.md +2 -379
- package/README.md +3 -1
- package/cli.js +34 -118
- package/lib/ninja-request.js +247 -0
- package/lib/settings-gen.js +0 -13
- package/mcp-server.js +7 -33
- package/ninja-ensure.js +92 -16
- package/package.json +2 -3
- package/public/app.js +55 -308
- package/public/index.html +15 -44
- package/public/style.css +78 -6
- package/server.js +15 -6
- package/hooks/ninja-common.js +0 -46
- package/hooks/ninja-prompt-submit.js +0 -33
- package/hooks/ninja-startup.js +0 -95
package/public/app.js
CHANGED
|
@@ -3,313 +3,20 @@
|
|
|
3
3
|
const WS_BASE = `ws://${location.host}`;
|
|
4
4
|
const API_BASE = '';
|
|
5
5
|
const AUTH_API = '/api';
|
|
6
|
-
const TOKEN_KEY = 'ninja_token';
|
|
7
|
-
|
|
8
|
-
// Session readiness gate — resolves when session is validated (or validation is skipped)
|
|
9
|
-
let sessionReadyResolve;
|
|
10
|
-
const sessionReady = new Promise(resolve => { sessionReadyResolve = resolve; });
|
|
11
|
-
|
|
12
|
-
// ── Auth Module ──────────────────────────────────────────────
|
|
13
|
-
|
|
14
6
|
const auth = {
|
|
15
7
|
token: null,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
terminalsMax: 2,
|
|
19
|
-
validating: false,
|
|
20
|
-
|
|
21
|
-
init() {
|
|
22
|
-
const stored = localStorage.getItem(TOKEN_KEY);
|
|
23
|
-
if (!stored) return false;
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
// Decode JWT payload (base64url)
|
|
27
|
-
const parts = stored.split('.');
|
|
28
|
-
if (parts.length !== 3) {
|
|
29
|
-
localStorage.removeItem(TOKEN_KEY);
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
|
34
|
-
|
|
35
|
-
// Check expiration
|
|
36
|
-
if (payload.exp && payload.exp * 1000 < Date.now()) {
|
|
37
|
-
localStorage.removeItem(TOKEN_KEY);
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
this.token = stored;
|
|
42
|
-
this.user = payload.sub || payload.email || payload.username || null;
|
|
43
|
-
return true;
|
|
44
|
-
} catch {
|
|
45
|
-
localStorage.removeItem(TOKEN_KEY);
|
|
46
|
-
return false;
|
|
47
|
-
}
|
|
48
|
-
},
|
|
49
|
-
|
|
50
|
-
async tryBootstrap() {
|
|
51
|
-
try {
|
|
52
|
-
const res = await fetch(`${API_BASE}/api/auth/bootstrap`);
|
|
53
|
-
if (!res.ok) return false;
|
|
54
|
-
|
|
55
|
-
const data = await res.json();
|
|
56
|
-
if (!data.token) return false;
|
|
57
|
-
|
|
58
|
-
// Validate token format
|
|
59
|
-
const parts = data.token.split('.');
|
|
60
|
-
if (parts.length !== 3) return false;
|
|
61
|
-
|
|
62
|
-
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
|
63
|
-
if (payload.exp && payload.exp * 1000 < Date.now()) return false;
|
|
64
|
-
|
|
65
|
-
// Save to localStorage and use
|
|
66
|
-
localStorage.setItem(TOKEN_KEY, data.token);
|
|
67
|
-
this.token = data.token;
|
|
68
|
-
this.user = payload.sub || payload.email || payload.username || null;
|
|
69
|
-
return true;
|
|
70
|
-
} catch {
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
|
|
75
|
-
async login(usernameOrEmail, password) {
|
|
76
|
-
const res = await fetch(`${AUTH_API}/auth/login`, {
|
|
77
|
-
method: 'POST',
|
|
78
|
-
headers: { 'Content-Type': 'application/json' },
|
|
79
|
-
body: JSON.stringify({ username: usernameOrEmail, password }),
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
if (!res.ok) {
|
|
83
|
-
const err = await res.json().catch(() => ({}));
|
|
84
|
-
throw new Error(err.message || 'Login failed');
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const data = await res.json();
|
|
88
|
-
this.token = data.token || data.accessToken;
|
|
89
|
-
localStorage.setItem(TOKEN_KEY, this.token);
|
|
90
|
-
|
|
91
|
-
await this.validateTier();
|
|
92
|
-
return data;
|
|
93
|
-
},
|
|
94
|
-
|
|
95
|
-
async register(username, email, password) {
|
|
96
|
-
const res = await fetch(`${AUTH_API}/auth/register`, {
|
|
97
|
-
method: 'POST',
|
|
98
|
-
headers: { 'Content-Type': 'application/json' },
|
|
99
|
-
body: JSON.stringify({ username, email, password }),
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
if (!res.ok) {
|
|
103
|
-
const err = await res.json().catch(() => ({}));
|
|
104
|
-
throw new Error(err.message || 'Registration failed');
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const data = await res.json();
|
|
108
|
-
this.token = data.token || data.accessToken;
|
|
109
|
-
localStorage.setItem(TOKEN_KEY, this.token);
|
|
110
|
-
|
|
111
|
-
await this.validateTier();
|
|
112
|
-
return data;
|
|
8
|
+
getAuthHeader() {
|
|
9
|
+
return {};
|
|
113
10
|
},
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
method: 'POST',
|
|
118
|
-
headers: { 'Content-Type': 'application/json' },
|
|
119
|
-
body: JSON.stringify({ licenseKey: key }),
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
if (!res.ok) {
|
|
123
|
-
const err = await res.json().catch(() => ({}));
|
|
124
|
-
throw new Error(err.message || 'Invalid license key');
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const data = await res.json();
|
|
128
|
-
this.token = data.token || data.accessToken;
|
|
129
|
-
localStorage.setItem(TOKEN_KEY, this.token);
|
|
130
|
-
|
|
131
|
-
await this.validateTier();
|
|
132
|
-
return data;
|
|
11
|
+
logout() {},
|
|
12
|
+
init() {
|
|
13
|
+
return true;
|
|
133
14
|
},
|
|
134
|
-
|
|
135
15
|
async validateTier() {
|
|
136
|
-
|
|
137
|
-
try {
|
|
138
|
-
const res = await fetch(`${API_BASE}/api/session`, {
|
|
139
|
-
method: 'POST',
|
|
140
|
-
headers: {
|
|
141
|
-
'Content-Type': 'application/json',
|
|
142
|
-
...this.getAuthHeader(),
|
|
143
|
-
},
|
|
144
|
-
body: JSON.stringify({ token: this.token }),
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
if (!res.ok) {
|
|
148
|
-
// 401 = token truly invalid/expired, need re-login
|
|
149
|
-
if (res.status === 401) {
|
|
150
|
-
console.warn('Session validation failed: token invalid');
|
|
151
|
-
this.token = null;
|
|
152
|
-
localStorage.removeItem(TOKEN_KEY);
|
|
153
|
-
return { needsLogin: true };
|
|
154
|
-
}
|
|
155
|
-
// Other errors (500, network) — proceed with defaults
|
|
156
|
-
console.warn('Session validation failed, using defaults');
|
|
157
|
-
return { needsLogin: false };
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const data = await res.json();
|
|
161
|
-
this.tier = data.tier || 'free';
|
|
162
|
-
this.terminalsMax = data.terminalsMax || 2;
|
|
163
|
-
if (data.user) this.user = data.user;
|
|
164
|
-
return { needsLogin: false };
|
|
165
|
-
} finally {
|
|
166
|
-
this.validating = false;
|
|
167
|
-
}
|
|
168
|
-
},
|
|
169
|
-
|
|
170
|
-
async logout() {
|
|
171
|
-
try {
|
|
172
|
-
await fetch(`${API_BASE}/api/session`, {
|
|
173
|
-
method: 'DELETE',
|
|
174
|
-
headers: this.getAuthHeader(),
|
|
175
|
-
});
|
|
176
|
-
} catch {
|
|
177
|
-
// Ignore errors on logout
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
this.token = null;
|
|
181
|
-
this.user = null;
|
|
182
|
-
this.tier = null;
|
|
183
|
-
localStorage.removeItem(TOKEN_KEY);
|
|
184
|
-
|
|
185
|
-
showAuthOverlay();
|
|
186
|
-
},
|
|
187
|
-
|
|
188
|
-
getAuthHeader() {
|
|
189
|
-
return this.token ? { 'Authorization': `Bearer ${this.token}` } : {};
|
|
16
|
+
return { needsLogin: false };
|
|
190
17
|
},
|
|
191
18
|
};
|
|
192
19
|
|
|
193
|
-
// ── Auth UI ──────────────────────────────────────────────────
|
|
194
|
-
|
|
195
|
-
function showAuthOverlay() {
|
|
196
|
-
// Auth disabled - app is free, never show auth overlay
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function hideAuthOverlay() {
|
|
201
|
-
const overlay = document.getElementById('auth-overlay');
|
|
202
|
-
overlay.classList.add('hidden');
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function setupAuthForms() {
|
|
206
|
-
const loginForm = document.getElementById('login-form');
|
|
207
|
-
const registerForm = document.getElementById('register-form');
|
|
208
|
-
const licenseForm = document.getElementById('license-form');
|
|
209
|
-
const loginError = document.getElementById('login-error');
|
|
210
|
-
const registerError = document.getElementById('register-error');
|
|
211
|
-
const logoutBtn = document.getElementById('logout-btn');
|
|
212
|
-
const showRegisterLink = document.getElementById('show-register');
|
|
213
|
-
const authToggleText = document.getElementById('auth-toggle-text');
|
|
214
|
-
|
|
215
|
-
// Toggle between login and register
|
|
216
|
-
let showingRegister = false;
|
|
217
|
-
|
|
218
|
-
function toggleAuthMode() {
|
|
219
|
-
showingRegister = !showingRegister;
|
|
220
|
-
if (showingRegister) {
|
|
221
|
-
loginForm.classList.add('hidden');
|
|
222
|
-
registerForm.classList.remove('hidden');
|
|
223
|
-
authToggleText.innerHTML = 'Already have an account? <a href="#" id="show-register">Sign in</a>';
|
|
224
|
-
document.getElementById('register-username').focus();
|
|
225
|
-
} else {
|
|
226
|
-
registerForm.classList.add('hidden');
|
|
227
|
-
loginForm.classList.remove('hidden');
|
|
228
|
-
authToggleText.innerHTML = 'Don\'t have an account? <a href="#" id="show-register">Sign up</a>';
|
|
229
|
-
document.getElementById('login-email').focus();
|
|
230
|
-
}
|
|
231
|
-
// Re-attach click handler to new link
|
|
232
|
-
document.getElementById('show-register').addEventListener('click', (e) => {
|
|
233
|
-
e.preventDefault();
|
|
234
|
-
toggleAuthMode();
|
|
235
|
-
});
|
|
236
|
-
loginError.textContent = '';
|
|
237
|
-
registerError.textContent = '';
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
showRegisterLink.addEventListener('click', (e) => {
|
|
241
|
-
e.preventDefault();
|
|
242
|
-
toggleAuthMode();
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
// Login form
|
|
246
|
-
loginForm.addEventListener('submit', async (e) => {
|
|
247
|
-
e.preventDefault();
|
|
248
|
-
loginError.textContent = '';
|
|
249
|
-
|
|
250
|
-
const email = document.getElementById('login-email').value.trim();
|
|
251
|
-
const password = document.getElementById('login-password').value;
|
|
252
|
-
|
|
253
|
-
try {
|
|
254
|
-
await auth.login(email, password);
|
|
255
|
-
hideAuthOverlay();
|
|
256
|
-
startApp();
|
|
257
|
-
sessionReadyResolve();
|
|
258
|
-
} catch (err) {
|
|
259
|
-
loginError.textContent = err.message;
|
|
260
|
-
}
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
// Register form
|
|
264
|
-
registerForm.addEventListener('submit', async (e) => {
|
|
265
|
-
e.preventDefault();
|
|
266
|
-
registerError.textContent = '';
|
|
267
|
-
|
|
268
|
-
const username = document.getElementById('register-username').value.trim();
|
|
269
|
-
const email = document.getElementById('register-email').value.trim();
|
|
270
|
-
const password = document.getElementById('register-password').value;
|
|
271
|
-
|
|
272
|
-
if (password.length < 8) {
|
|
273
|
-
registerError.textContent = 'Password must be at least 8 characters';
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
try {
|
|
278
|
-
await auth.register(username, email, password);
|
|
279
|
-
hideAuthOverlay();
|
|
280
|
-
startApp();
|
|
281
|
-
sessionReadyResolve();
|
|
282
|
-
} catch (err) {
|
|
283
|
-
registerError.textContent = err.message;
|
|
284
|
-
}
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
// License form
|
|
288
|
-
licenseForm.addEventListener('submit', async (e) => {
|
|
289
|
-
e.preventDefault();
|
|
290
|
-
loginError.textContent = '';
|
|
291
|
-
|
|
292
|
-
const key = document.getElementById('license-key').value.trim();
|
|
293
|
-
if (!key) {
|
|
294
|
-
loginError.textContent = 'Please enter a license key';
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
try {
|
|
299
|
-
await auth.activateLicense(key);
|
|
300
|
-
hideAuthOverlay();
|
|
301
|
-
startApp();
|
|
302
|
-
sessionReadyResolve();
|
|
303
|
-
} catch (err) {
|
|
304
|
-
loginError.textContent = err.message;
|
|
305
|
-
}
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
logoutBtn.addEventListener('click', () => {
|
|
309
|
-
auth.logout();
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
|
|
313
20
|
// ── State ────────────────────────────────────────────────────
|
|
314
21
|
|
|
315
22
|
const state = {
|
|
@@ -376,6 +83,24 @@ const TASK_STATUS_LABELS = {
|
|
|
376
83
|
unknown: 'UNKNOWN',
|
|
377
84
|
};
|
|
378
85
|
|
|
86
|
+
const AGENT_TYPE_LABELS = {
|
|
87
|
+
claude: 'Claude',
|
|
88
|
+
codex: 'Codex',
|
|
89
|
+
opencode: 'OpenCode',
|
|
90
|
+
shell: 'Shell',
|
|
91
|
+
mixed: 'Mixed',
|
|
92
|
+
duo: 'Duo',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const AGENT_TYPE_CLASSES = {
|
|
96
|
+
claude: 'agent-claude',
|
|
97
|
+
codex: 'agent-codex',
|
|
98
|
+
opencode: 'agent-opencode',
|
|
99
|
+
shell: 'agent-shell',
|
|
100
|
+
mixed: 'agent-mixed',
|
|
101
|
+
duo: 'agent-duo',
|
|
102
|
+
};
|
|
103
|
+
|
|
379
104
|
// ── Utilities ────────────────────────────────────────────────
|
|
380
105
|
|
|
381
106
|
function timestamp() {
|
|
@@ -393,6 +118,10 @@ function getTerminalFeedClass(id) {
|
|
|
393
118
|
return `feed-t${idx + 1}`;
|
|
394
119
|
}
|
|
395
120
|
|
|
121
|
+
function getAgentTypeClass(agentType) {
|
|
122
|
+
return AGENT_TYPE_CLASSES[agentType] || 'agent-claude';
|
|
123
|
+
}
|
|
124
|
+
|
|
396
125
|
// ── Activity Feed ────────────────────────────────────────────
|
|
397
126
|
|
|
398
127
|
function addFeedEntry(message, terminalId) {
|
|
@@ -423,12 +152,13 @@ function escapeHtml(str) {
|
|
|
423
152
|
// ── Terminal Creation ────────────────────────────────────────
|
|
424
153
|
|
|
425
154
|
function createTerminalUI(termData) {
|
|
426
|
-
const { id, label, status, elapsed, progress, taskName } = termData;
|
|
155
|
+
const { id, label, status, elapsed, progress, taskName, agentType } = termData;
|
|
427
156
|
|
|
428
157
|
// Pane
|
|
429
158
|
const pane = document.createElement('div');
|
|
430
159
|
pane.className = 'terminal-pane';
|
|
431
160
|
pane.id = `pane-${id}`;
|
|
161
|
+
pane.classList.add(getAgentTypeClass(agentType));
|
|
432
162
|
|
|
433
163
|
// Header
|
|
434
164
|
const header = document.createElement('div');
|
|
@@ -660,6 +390,7 @@ function createTerminalUI(termData) {
|
|
|
660
390
|
const termState = {
|
|
661
391
|
id,
|
|
662
392
|
label: label || `Terminal ${id.slice(0, 6)}`,
|
|
393
|
+
agentType: agentType || 'claude',
|
|
663
394
|
status: status || 'idle',
|
|
664
395
|
progress: progress || 0,
|
|
665
396
|
elapsed: elapsed || '',
|
|
@@ -964,6 +695,11 @@ function updateTerminalState(id, newStatus, extra) {
|
|
|
964
695
|
if (t.labelEl) t.labelEl.textContent = extra.label;
|
|
965
696
|
}
|
|
966
697
|
if (extra.taskName !== undefined) t.taskName = extra.taskName;
|
|
698
|
+
if (extra.agentType !== undefined) {
|
|
699
|
+
t.agentType = extra.agentType;
|
|
700
|
+
t.paneEl.classList.remove('agent-claude', 'agent-codex', 'agent-opencode', 'agent-shell', 'agent-mixed', 'agent-duo');
|
|
701
|
+
t.paneEl.classList.add(getAgentTypeClass(extra.agentType));
|
|
702
|
+
}
|
|
967
703
|
}
|
|
968
704
|
|
|
969
705
|
// Update state icon
|
|
@@ -1248,7 +984,6 @@ window.addEventListener('resize', () => {
|
|
|
1248
984
|
// ── Start App (after auth) ───────────────────────────────────
|
|
1249
985
|
|
|
1250
986
|
async function startApp() {
|
|
1251
|
-
// Request desktop notification permission
|
|
1252
987
|
requestNotificationPermission();
|
|
1253
988
|
|
|
1254
989
|
// Setup sidebar
|
|
@@ -1294,23 +1029,37 @@ async function startApp() {
|
|
|
1294
1029
|
|
|
1295
1030
|
function setupAddTerminal() {
|
|
1296
1031
|
const btn = document.getElementById('add-terminal-btn');
|
|
1032
|
+
const preset = document.getElementById('agent-preset-select');
|
|
1297
1033
|
if (!btn) return;
|
|
1298
1034
|
|
|
1299
1035
|
// Store last used directory
|
|
1300
1036
|
let lastCwd = localStorage.getItem('ninja-last-cwd') || '/Users/davidmini/Desktop/Projects';
|
|
1037
|
+
let lastAgentType = localStorage.getItem('ninja-last-agent-type') || 'claude';
|
|
1038
|
+
|
|
1039
|
+
if (preset) {
|
|
1040
|
+
const validPresets = new Set(['claude', 'codex', 'opencode', 'shell', 'mixed', 'duo']);
|
|
1041
|
+
if (!validPresets.has(lastAgentType)) lastAgentType = 'claude';
|
|
1042
|
+
preset.value = lastAgentType;
|
|
1043
|
+
preset.addEventListener('change', () => {
|
|
1044
|
+
lastAgentType = preset.value;
|
|
1045
|
+
localStorage.setItem('ninja-last-agent-type', lastAgentType);
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1301
1048
|
|
|
1302
1049
|
btn.addEventListener('click', async () => {
|
|
1303
1050
|
try {
|
|
1304
|
-
const
|
|
1305
|
-
|
|
1051
|
+
const agentType = preset?.value || lastAgentType || 'claude';
|
|
1052
|
+
const cwd = lastCwd;
|
|
1306
1053
|
|
|
1307
1054
|
lastCwd = cwd;
|
|
1055
|
+
lastAgentType = agentType;
|
|
1308
1056
|
localStorage.setItem('ninja-last-cwd', cwd);
|
|
1057
|
+
localStorage.setItem('ninja-last-agent-type', agentType);
|
|
1309
1058
|
|
|
1310
1059
|
const res = await fetch(`${API_BASE}/api/terminals`, {
|
|
1311
1060
|
method: 'POST',
|
|
1312
1061
|
headers: { 'Content-Type': 'application/json', ...auth.getAuthHeader() },
|
|
1313
|
-
body: JSON.stringify({ cwd }),
|
|
1062
|
+
body: JSON.stringify({ cwd, agentType }),
|
|
1314
1063
|
});
|
|
1315
1064
|
|
|
1316
1065
|
if (!res.ok) {
|
|
@@ -1321,7 +1070,7 @@ function setupAddTerminal() {
|
|
|
1321
1070
|
|
|
1322
1071
|
const terminal = await res.json();
|
|
1323
1072
|
createTerminalUI(terminal);
|
|
1324
|
-
addFeedEntry(`Terminal added: T${terminal.id}`);
|
|
1073
|
+
addFeedEntry(`Terminal added: T${terminal.id} (${AGENT_TYPE_LABELS[terminal.agentType] || terminal.agentType || 'claude'})`);
|
|
1325
1074
|
} catch (err) {
|
|
1326
1075
|
console.error('Failed to add terminal:', err);
|
|
1327
1076
|
alert('Failed to add terminal');
|
|
@@ -1401,10 +1150,8 @@ function setupLearnings() {
|
|
|
1401
1150
|
// ── Initialize ───────────────────────────────────────────────
|
|
1402
1151
|
|
|
1403
1152
|
async function init() {
|
|
1404
|
-
|
|
1405
|
-
hideAuthOverlay();
|
|
1153
|
+
auth.init();
|
|
1406
1154
|
startApp();
|
|
1407
|
-
sessionReadyResolve();
|
|
1408
1155
|
}
|
|
1409
1156
|
|
|
1410
1157
|
init();
|
package/public/index.html
CHANGED
|
@@ -26,56 +26,27 @@
|
|
|
26
26
|
</div>
|
|
27
27
|
</div>
|
|
28
28
|
|
|
29
|
-
<!-- Auth Overlay (disabled - app is free) -->
|
|
30
|
-
<div id="auth-overlay" style="display:none;">
|
|
31
|
-
<div class="auth-card">
|
|
32
|
-
<div class="auth-stripes">
|
|
33
|
-
<div class="stripe s1"></div>
|
|
34
|
-
<div class="stripe s2"></div>
|
|
35
|
-
<div class="stripe s3"></div>
|
|
36
|
-
<div class="stripe s4"></div>
|
|
37
|
-
</div>
|
|
38
|
-
<div class="auth-card-inner">
|
|
39
|
-
<h1 class="logo-text">NINJA TERMINALS</h1>
|
|
40
|
-
<p class="auth-subtitle">Multi-Agent Claude Code Orchestrator</p>
|
|
41
|
-
<!-- Login Form -->
|
|
42
|
-
<form id="login-form">
|
|
43
|
-
<input type="text" id="login-email" placeholder="Email or username" required autocomplete="username">
|
|
44
|
-
<input type="password" id="login-password" placeholder="Password" required autocomplete="current-password">
|
|
45
|
-
<button type="submit" class="auth-btn">Sign In</button>
|
|
46
|
-
<p class="auth-error" id="login-error"></p>
|
|
47
|
-
</form>
|
|
48
|
-
|
|
49
|
-
<!-- Register Form (hidden by default) -->
|
|
50
|
-
<form id="register-form" class="hidden">
|
|
51
|
-
<input type="text" id="register-username" placeholder="Username" required autocomplete="username">
|
|
52
|
-
<input type="email" id="register-email" placeholder="Email" required autocomplete="email">
|
|
53
|
-
<input type="password" id="register-password" placeholder="Password (min 8 chars)" required autocomplete="new-password" minlength="8">
|
|
54
|
-
<button type="submit" class="auth-btn">Create Account</button>
|
|
55
|
-
<p class="auth-error" id="register-error"></p>
|
|
56
|
-
</form>
|
|
57
|
-
|
|
58
|
-
<div class="auth-divider"><span>or</span></div>
|
|
59
|
-
<form id="license-form">
|
|
60
|
-
<input type="text" id="license-key" placeholder="Enter license key" autocomplete="off">
|
|
61
|
-
<button type="submit" class="auth-btn auth-btn-secondary">Activate License</button>
|
|
62
|
-
</form>
|
|
63
|
-
<p class="auth-footer" id="auth-toggle-text">Don't have an account? <a href="#" id="show-register">Sign up</a></p>
|
|
64
|
-
</div>
|
|
65
|
-
</div>
|
|
66
|
-
</div>
|
|
67
|
-
|
|
68
29
|
<div id="app">
|
|
69
30
|
<aside id="sidebar">
|
|
70
31
|
<div class="sidebar-header">
|
|
71
32
|
<div class="logo-card">
|
|
72
33
|
<span class="logo">NINJA TERMINALS</span>
|
|
73
34
|
<span class="logo-sub">Multi-Agent Orchestrator</span>
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
35
|
+
<label class="agent-preset" for="agent-preset-select">
|
|
36
|
+
<span class="agent-preset-label">Agent preset</span>
|
|
37
|
+
<select id="agent-preset-select" class="agent-preset-select" aria-label="Agent preset">
|
|
38
|
+
<option value="claude">Claude</option>
|
|
39
|
+
<option value="codex">Codex</option>
|
|
40
|
+
<option value="opencode">OpenCode</option>
|
|
41
|
+
<option value="shell">Shell</option>
|
|
42
|
+
<option value="mixed">Mixed</option>
|
|
43
|
+
<option value="duo">Duo</option>
|
|
44
|
+
</select>
|
|
45
|
+
</label>
|
|
46
|
+
<button id="add-terminal-btn" class="add-terminal-btn" title="Add Terminal">+</button>
|
|
47
|
+
<button id="clear-all-btn" class="clear-all-btn" title="Clear All Terminals">🗑</button>
|
|
48
|
+
<button id="learnings-btn" class="learnings-btn" title="View Session Learnings">🧠</button>
|
|
49
|
+
</div>
|
|
79
50
|
<div class="logo-stripes">
|
|
80
51
|
<div class="stripe s1"></div>
|
|
81
52
|
<div class="stripe s2"></div>
|
package/public/style.css
CHANGED
|
@@ -301,6 +301,7 @@ main {
|
|
|
301
301
|
position: relative;
|
|
302
302
|
border: 1px solid var(--border);
|
|
303
303
|
transition: border-color 0.2s;
|
|
304
|
+
--agent-accent: var(--feed-t1);
|
|
304
305
|
}
|
|
305
306
|
|
|
306
307
|
.terminal-pane.active {
|
|
@@ -349,6 +350,24 @@ main {
|
|
|
349
350
|
text-shadow: 0 0 10px rgba(0, 0, 0, 0.8);
|
|
350
351
|
}
|
|
351
352
|
|
|
353
|
+
.terminal-pane.agent-claude { --agent-accent: var(--feed-t1); }
|
|
354
|
+
.terminal-pane.agent-codex { --agent-accent: var(--feed-t2); }
|
|
355
|
+
.terminal-pane.agent-opencode { --agent-accent: var(--feed-t3); }
|
|
356
|
+
.terminal-pane.agent-shell { --agent-accent: var(--feed-t4); }
|
|
357
|
+
.terminal-pane.agent-mixed { --agent-accent: var(--state-working); }
|
|
358
|
+
.terminal-pane.agent-duo { --agent-accent: var(--state-done); }
|
|
359
|
+
|
|
360
|
+
.terminal-pane::before {
|
|
361
|
+
content: '';
|
|
362
|
+
position: absolute;
|
|
363
|
+
inset: 0 auto 0 0;
|
|
364
|
+
width: 4px;
|
|
365
|
+
background: var(--agent-accent);
|
|
366
|
+
box-shadow: 0 0 12px color-mix(in srgb, var(--agent-accent) 55%, transparent);
|
|
367
|
+
pointer-events: none;
|
|
368
|
+
z-index: 2;
|
|
369
|
+
}
|
|
370
|
+
|
|
352
371
|
/* ── Pane Header ────────────────────────────── */
|
|
353
372
|
|
|
354
373
|
.pane-header {
|
|
@@ -362,6 +381,17 @@ main {
|
|
|
362
381
|
cursor: default;
|
|
363
382
|
user-select: none;
|
|
364
383
|
position: relative;
|
|
384
|
+
overflow: hidden;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.pane-header::after {
|
|
388
|
+
content: '';
|
|
389
|
+
position: absolute;
|
|
390
|
+
inset: auto 0 0 0;
|
|
391
|
+
height: 2px;
|
|
392
|
+
background: linear-gradient(90deg, var(--agent-accent), color-mix(in srgb, var(--agent-accent) 35%, transparent));
|
|
393
|
+
opacity: 0.9;
|
|
394
|
+
pointer-events: none;
|
|
365
395
|
}
|
|
366
396
|
|
|
367
397
|
/* ── Pane Label (retro cream badge) ── */
|
|
@@ -371,7 +401,7 @@ main {
|
|
|
371
401
|
font-size: 16px;
|
|
372
402
|
letter-spacing: 2px;
|
|
373
403
|
color: var(--bg);
|
|
374
|
-
background: var(--cream);
|
|
404
|
+
background: linear-gradient(90deg, var(--cream), color-mix(in srgb, var(--cream) 78%, var(--agent-accent) 22%));
|
|
375
405
|
padding: 2px 10px;
|
|
376
406
|
border-radius: 2px;
|
|
377
407
|
white-space: nowrap;
|
|
@@ -382,11 +412,13 @@ main {
|
|
|
382
412
|
position: relative;
|
|
383
413
|
}
|
|
384
414
|
|
|
385
|
-
/*
|
|
386
|
-
.terminal-pane
|
|
387
|
-
.terminal-pane
|
|
388
|
-
.terminal-pane
|
|
389
|
-
.terminal-pane
|
|
415
|
+
/* Agent-based color band on left edge of badge */
|
|
416
|
+
.terminal-pane.agent-claude .pane-label { border-left: 3px solid var(--feed-t1); }
|
|
417
|
+
.terminal-pane.agent-codex .pane-label { border-left: 3px solid var(--feed-t2); }
|
|
418
|
+
.terminal-pane.agent-opencode .pane-label { border-left: 3px solid var(--feed-t3); }
|
|
419
|
+
.terminal-pane.agent-shell .pane-label { border-left: 3px solid var(--feed-t4); }
|
|
420
|
+
.terminal-pane.agent-mixed .pane-label { border-left: 3px solid var(--state-working); }
|
|
421
|
+
.terminal-pane.agent-duo .pane-label { border-left: 3px solid var(--state-done); }
|
|
390
422
|
|
|
391
423
|
.pane-label.editing {
|
|
392
424
|
background: #1a1a1a;
|
|
@@ -401,6 +433,46 @@ main {
|
|
|
401
433
|
font-family: 'Space Grotesk', sans-serif;
|
|
402
434
|
}
|
|
403
435
|
|
|
436
|
+
.agent-preset {
|
|
437
|
+
display: flex;
|
|
438
|
+
flex-direction: column;
|
|
439
|
+
gap: 4px;
|
|
440
|
+
margin: 10px 0 8px;
|
|
441
|
+
padding: 0 16px;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
.agent-preset-label {
|
|
445
|
+
font-family: 'Space Grotesk', sans-serif;
|
|
446
|
+
font-size: 10px;
|
|
447
|
+
font-weight: 700;
|
|
448
|
+
letter-spacing: 1px;
|
|
449
|
+
text-transform: uppercase;
|
|
450
|
+
color: var(--cream-dark);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
.agent-preset-select {
|
|
454
|
+
width: 100%;
|
|
455
|
+
background: #141414;
|
|
456
|
+
border: 1px solid var(--border);
|
|
457
|
+
color: var(--text-bright);
|
|
458
|
+
font-family: 'Space Grotesk', sans-serif;
|
|
459
|
+
font-size: 11px;
|
|
460
|
+
font-weight: 600;
|
|
461
|
+
padding: 6px 8px;
|
|
462
|
+
border-radius: 3px;
|
|
463
|
+
outline: none;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.agent-preset-select:focus {
|
|
467
|
+
border-color: var(--border-active);
|
|
468
|
+
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.05);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
.agent-preset-select option {
|
|
472
|
+
background: var(--surface);
|
|
473
|
+
color: var(--text-bright);
|
|
474
|
+
}
|
|
475
|
+
|
|
404
476
|
.pane-state {
|
|
405
477
|
display: flex;
|
|
406
478
|
align-items: center;
|