@jhizzard/termdeck 0.10.4 → 0.12.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/package.json +3 -2
- package/packages/cli/src/init-rumen.js +153 -83
- package/packages/client/public/app.js +207 -4
- package/packages/client/public/flashback-history.html +331 -0
- package/packages/client/public/flashback-history.js +258 -0
- package/packages/client/public/graph-controls.js +217 -0
- package/packages/client/public/graph.html +36 -0
- package/packages/client/public/graph.js +131 -15
- package/packages/client/public/index.html +25 -0
- package/packages/client/public/style.css +230 -0
- package/packages/server/src/config.js +49 -0
- package/packages/server/src/database.js +49 -1
- package/packages/server/src/flashback-diag.js +187 -13
- package/packages/server/src/index.js +132 -19
- package/packages/server/src/projects-routes.js +119 -0
- package/packages/server/src/pty-reaper.js +297 -0
- package/packages/server/src/setup/index.js +1 -0
- package/packages/server/src/setup/migration-templating.js +76 -0
- package/packages/server/src/setup/migrations.js +44 -4
- package/packages/server/src/setup/rumen/functions/graph-inference/index.ts +381 -0
- package/packages/server/src/setup/rumen/functions/graph-inference/tsconfig.json +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
|
|
5
5
|
"bin": {
|
|
6
6
|
"termdeck": "./packages/cli/src/index.js"
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"server": "node packages/server/src/index.js",
|
|
30
30
|
"start": "NODE_ENV=production node packages/cli/src/index.js",
|
|
31
31
|
"test": "node --test packages/server/tests/**/*.test.js",
|
|
32
|
-
"install:app": "bash install.sh"
|
|
32
|
+
"install:app": "bash install.sh",
|
|
33
|
+
"sync-rumen-functions": "bash scripts/sync-rumen-functions.sh"
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
|
35
36
|
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
|
|
@@ -18,10 +18,12 @@
|
|
|
18
18
|
// 2. Derive project ref from SUPABASE_URL; confirm with user
|
|
19
19
|
// 3. supabase link --project-ref <ref>
|
|
20
20
|
// 4. Apply rumen migration 001 via pg
|
|
21
|
-
// 5. supabase functions deploy rumen-tick
|
|
21
|
+
// 5. supabase functions deploy rumen-tick AND graph-inference (Sprint 43 T3)
|
|
22
|
+
// from a single staging dir with multi-function supabase/config.toml
|
|
22
23
|
// 6. supabase secrets set DATABASE_URL=... ANTHROPIC_API_KEY=... [OPENAI_API_KEY=...]
|
|
23
|
-
// 7. Test
|
|
24
|
-
// 8. Apply pg_cron schedule
|
|
24
|
+
// 7. Test rumen-tick with a manual POST (graph-inference is cron-only)
|
|
25
|
+
// 8. Apply pg_cron schedule migrations 002 (rumen-tick) AND 003 (graph-inference)
|
|
26
|
+
// with project ref substituted
|
|
25
27
|
|
|
26
28
|
const path = require('path');
|
|
27
29
|
const fs = require('fs');
|
|
@@ -34,6 +36,7 @@ const {
|
|
|
34
36
|
dotenv,
|
|
35
37
|
supabaseUrl: urlHelper,
|
|
36
38
|
migrations,
|
|
39
|
+
migrationTemplating,
|
|
37
40
|
pgRunner,
|
|
38
41
|
preconditions
|
|
39
42
|
} = require(SETUP_DIR);
|
|
@@ -300,67 +303,98 @@ async function applyRumenTables(secrets, dryRun) {
|
|
|
300
303
|
}
|
|
301
304
|
}
|
|
302
305
|
|
|
303
|
-
function
|
|
304
|
-
|
|
306
|
+
// Sprint 43 T3: rumen-tick is the only function with a `__RUMEN_VERSION__`
|
|
307
|
+
// placeholder (its `npm:@jhizzard/rumen@<ver>` import is rewritten at deploy
|
|
308
|
+
// time). graph-inference pins its own deps (`npm:postgres@3.4.4`) and is
|
|
309
|
+
// copied verbatim. If a future function adds a placeholder, list it here.
|
|
310
|
+
const FUNCTIONS_WITH_VERSION_PLACEHOLDER = new Set(['rumen-tick']);
|
|
311
|
+
|
|
312
|
+
function deployFunctions(rumenVersion, dryRun) {
|
|
313
|
+
const fnNames = migrations.listRumenFunctions();
|
|
314
|
+
if (fnNames.length === 0) {
|
|
315
|
+
fail('no Rumen Edge Function source found in bundled setup or @jhizzard/rumen package');
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
step(`Staging ${fnNames.length} Edge Function(s) (${fnNames.join(', ')})...`);
|
|
305
320
|
if (dryRun) { ok('(dry-run)'); return true; }
|
|
306
321
|
|
|
307
|
-
//
|
|
308
|
-
// `supabase
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
322
|
+
// Stage all functions in one directory and one config.toml so a single
|
|
323
|
+
// `supabase functions deploy <name>` invocation per function can share the
|
|
324
|
+
// project root. This mirrors how a real Supabase repo is laid out.
|
|
325
|
+
let stage;
|
|
326
|
+
try {
|
|
327
|
+
stage = stageRumenFunctions(rumenVersion);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
fail(err.message);
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
312
332
|
if (!stage) {
|
|
313
|
-
fail('could not stage
|
|
333
|
+
fail('could not stage Rumen Edge Function source');
|
|
314
334
|
return false;
|
|
315
335
|
}
|
|
316
|
-
|
|
317
|
-
const r = runShell('supabase', ['functions', 'deploy', 'rumen-tick', '--no-verify-jwt'], {
|
|
318
|
-
cwd: stage
|
|
319
|
-
});
|
|
320
|
-
if (!r.ok) { fail(`deploy failed (exit ${r.code})`); return false; }
|
|
321
336
|
ok();
|
|
337
|
+
|
|
338
|
+
for (const name of fnNames) {
|
|
339
|
+
step(`Running: supabase functions deploy ${name} --no-verify-jwt...`);
|
|
340
|
+
const r = runShell('supabase', ['functions', 'deploy', name, '--no-verify-jwt'], {
|
|
341
|
+
cwd: stage
|
|
342
|
+
});
|
|
343
|
+
if (!r.ok) {
|
|
344
|
+
fail(`deploy of ${name} failed (exit ${r.code})`);
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
ok();
|
|
348
|
+
}
|
|
322
349
|
return true;
|
|
323
350
|
}
|
|
324
351
|
|
|
325
|
-
// Create a staging directory containing:
|
|
326
|
-
// <stage>/supabase/functions
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
352
|
+
// Create a staging directory containing every bundled Rumen Edge Function:
|
|
353
|
+
// <stage>/supabase/functions/<name>/{index.ts, tsconfig.json}
|
|
354
|
+
// <stage>/supabase/config.toml (one [functions.<name>] block per function)
|
|
355
|
+
//
|
|
356
|
+
// `__RUMEN_VERSION__` is substituted only in functions listed in
|
|
357
|
+
// FUNCTIONS_WITH_VERSION_PLACEHOLDER (currently just rumen-tick). Other
|
|
358
|
+
// files are copied verbatim. Returns the staging dir path or null if no
|
|
359
|
+
// function source could be located.
|
|
360
|
+
function stageRumenFunctions(rumenVersion) {
|
|
330
361
|
if (!rumenVersion || !/^\d+\.\d+\.\d+/.test(rumenVersion)) {
|
|
331
|
-
throw new Error(`
|
|
362
|
+
throw new Error(`stageRumenFunctions: invalid rumenVersion ${JSON.stringify(rumenVersion)}`);
|
|
332
363
|
}
|
|
364
|
+
const root = migrations.rumenFunctionsRoot();
|
|
365
|
+
const fnNames = migrations.listRumenFunctions();
|
|
366
|
+
if (fnNames.length === 0) return null;
|
|
367
|
+
|
|
333
368
|
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'termdeck-rumen-stage-'));
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
369
|
+
|
|
370
|
+
for (const name of fnNames) {
|
|
371
|
+
const fnSrc = path.join(root, name);
|
|
372
|
+
const fnDest = path.join(stage, 'supabase', 'functions', name);
|
|
373
|
+
fs.mkdirSync(fnDest, { recursive: true });
|
|
374
|
+
for (const f of fs.readdirSync(fnSrc)) {
|
|
375
|
+
const srcPath = path.join(fnSrc, f);
|
|
376
|
+
const destPath = path.join(fnDest, f);
|
|
377
|
+
if (f === 'index.ts' && FUNCTIONS_WITH_VERSION_PLACEHOLDER.has(name)) {
|
|
378
|
+
const raw = fs.readFileSync(srcPath, 'utf-8');
|
|
379
|
+
if (!raw.includes('__RUMEN_VERSION__')) {
|
|
380
|
+
throw new Error(
|
|
381
|
+
`${name}/index.ts is missing the __RUMEN_VERSION__ placeholder — ` +
|
|
382
|
+
`has someone reintroduced a hardcoded version? ` +
|
|
383
|
+
`Re-run scripts/sync-rumen-functions.sh to repopulate the placeholder.`
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
fs.writeFileSync(destPath, raw.replace(/__RUMEN_VERSION__/g, rumenVersion));
|
|
387
|
+
} else {
|
|
388
|
+
fs.copyFileSync(srcPath, destPath);
|
|
351
389
|
}
|
|
352
|
-
fs.writeFileSync(destPath, raw.replace(/__RUMEN_VERSION__/g, rumenVersion));
|
|
353
|
-
} else {
|
|
354
|
-
fs.copyFileSync(srcPath, destPath);
|
|
355
390
|
}
|
|
356
391
|
}
|
|
357
392
|
|
|
393
|
+
const fnBlocks = fnNames.map((name) => `[functions.${name}]\nverify_jwt = false\n`).join('\n');
|
|
358
394
|
const configToml = `# staged by termdeck init --rumen
|
|
359
395
|
project_id = "termdeck-rumen-stage"
|
|
360
396
|
|
|
361
|
-
|
|
362
|
-
verify_jwt = false
|
|
363
|
-
`;
|
|
397
|
+
${fnBlocks}`;
|
|
364
398
|
fs.writeFileSync(path.join(stage, 'supabase', 'config.toml'), configToml);
|
|
365
399
|
|
|
366
400
|
return stage;
|
|
@@ -428,26 +462,53 @@ async function testFunction(projectRef, secrets, dryRun) {
|
|
|
428
462
|
return true;
|
|
429
463
|
}
|
|
430
464
|
|
|
465
|
+
// Sprint 42 T3: bundle of cron-schedule migrations whose `<project-ref>`
|
|
466
|
+
// placeholder must be substituted at apply-time. Both are idempotent
|
|
467
|
+
// (cron.unschedule + cron.schedule), so applying in sequence is safe even
|
|
468
|
+
// when one was already installed. Pre-Sprint 42, only 002 was applied —
|
|
469
|
+
// migration 003 (graph-inference-tick) shipped bundled but unsubstituted
|
|
470
|
+
// and unscheduled, which is part of why Sprint 38 close-out left the
|
|
471
|
+
// graph-inference cron disabled.
|
|
472
|
+
const SCHEDULE_MIGRATIONS = [
|
|
473
|
+
{ matcher: /002.*pg_cron/, label: '002_pg_cron_schedule (rumen-tick)' },
|
|
474
|
+
{ matcher: /003.*graph_inference/, label: '003_graph_inference_schedule (graph-inference-tick)' }
|
|
475
|
+
];
|
|
476
|
+
|
|
431
477
|
async function applySchedule(projectRef, secrets, dryRun) {
|
|
432
|
-
step('Applying pg_cron
|
|
478
|
+
step('Applying pg_cron schedules (rumen-tick + graph-inference-tick)...');
|
|
433
479
|
if (dryRun) { ok('(dry-run)'); return true; }
|
|
434
480
|
|
|
435
481
|
const files = migrations.listRumenMigrations();
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
482
|
+
const planned = [];
|
|
483
|
+
for (const { matcher, label } of SCHEDULE_MIGRATIONS) {
|
|
484
|
+
const file = files.find((f) => matcher.test(path.basename(f)));
|
|
485
|
+
if (!file) { fail(`bundled ${label} is missing`); return false; }
|
|
486
|
+
planned.push({ file, label });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Substitute the project ref into each schedule body. Both bundled
|
|
490
|
+
// migrations ship with the `<project-ref>` placeholder per Rumen's
|
|
491
|
+
// deploy docs; the helper also accepts `{{PROJECT_REF}}` for robustness
|
|
492
|
+
// and refuses to ship an unsubstituted placeholder to the database.
|
|
493
|
+
const substituted = [];
|
|
494
|
+
try {
|
|
495
|
+
for (const { file, label } of planned) {
|
|
496
|
+
const raw = migrations.readFile(file);
|
|
497
|
+
substituted.push({
|
|
498
|
+
sql: migrationTemplating.applyTemplating(raw, { projectRef }),
|
|
499
|
+
label
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
} catch (err) {
|
|
503
|
+
fail(err.message);
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// The shipped migrations use Supabase Vault (`vault.decrypted_secrets`)
|
|
508
|
+
// to pull the service-role keys (`rumen_service_role_key` for 002 and
|
|
509
|
+
// `graph_inference_service_role_key` for 003). If a key isn't stored in
|
|
510
|
+
// Vault the corresponding cron call will fail at runtime. We leave that
|
|
511
|
+
// as a post-install step and print a reminder below.
|
|
451
512
|
|
|
452
513
|
let client;
|
|
453
514
|
try {
|
|
@@ -457,20 +518,21 @@ async function applySchedule(projectRef, secrets, dryRun) {
|
|
|
457
518
|
return false;
|
|
458
519
|
}
|
|
459
520
|
try {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
return false;
|
|
521
|
+
for (const { sql, label } of substituted) {
|
|
522
|
+
try {
|
|
523
|
+
await pgRunner.run(client, sql);
|
|
524
|
+
} catch (err) {
|
|
525
|
+
fail(`${label}: ${err.message}`);
|
|
526
|
+
process.stderr.write(
|
|
527
|
+
'\nThe schedule SQL failed — the most common cause is that pg_cron or pg_net\n' +
|
|
528
|
+
'is not enabled in the Supabase project. Enable them in Dashboard → Database\n' +
|
|
529
|
+
'→ Extensions, then re-run `termdeck init --rumen --skip-schedule=false`.\n'
|
|
530
|
+
);
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
473
533
|
}
|
|
534
|
+
ok();
|
|
535
|
+
return true;
|
|
474
536
|
} finally {
|
|
475
537
|
try { await client.end(); } catch (_err) { /* ignore */ }
|
|
476
538
|
}
|
|
@@ -540,7 +602,8 @@ function wireAccessTokenInMcpJson({ token, mcpJsonPath, _testFs } = {}) {
|
|
|
540
602
|
}
|
|
541
603
|
|
|
542
604
|
function printNextSteps(projectRef) {
|
|
543
|
-
const
|
|
605
|
+
const rumenTickUrl = `https://${projectRef}.supabase.co/functions/v1/rumen-tick`;
|
|
606
|
+
const graphInferenceUrl = `https://${projectRef}.supabase.co/functions/v1/graph-inference`;
|
|
544
607
|
const now = new Date();
|
|
545
608
|
// Round up to the next 15-minute mark so the hint is accurate.
|
|
546
609
|
const next = new Date(now.getTime());
|
|
@@ -548,16 +611,20 @@ function printNextSteps(projectRef) {
|
|
|
548
611
|
process.stdout.write(`
|
|
549
612
|
Rumen is deployed.
|
|
550
613
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
614
|
+
Edge Functions:
|
|
615
|
+
rumen-tick every 15 min — first run: ${next.toISOString().replace(/\.\d+Z$/, 'Z')}
|
|
616
|
+
${rumenTickUrl}
|
|
617
|
+
graph-inference daily at 03:00 UTC (Sprint 42 cron)
|
|
618
|
+
${graphInferenceUrl}
|
|
554
619
|
|
|
555
620
|
Next steps:
|
|
556
|
-
1. Monitor:
|
|
557
|
-
2. Store
|
|
558
|
-
|
|
621
|
+
1. Monitor rumen jobs: psql "$DATABASE_URL" -c "SELECT * FROM rumen_jobs ORDER BY started_at DESC LIMIT 5"
|
|
622
|
+
2. Store service_role keys in Supabase Vault — required for both cron schedules:
|
|
623
|
+
rumen_service_role_key (used by 002_pg_cron_schedule.sql)
|
|
624
|
+
graph_inference_service_role_key (used by 003_graph_inference_schedule.sql)
|
|
559
625
|
3. Rumen insights flow back into Mnestra's memory_items via rumen_insights.
|
|
560
|
-
4.
|
|
626
|
+
4. graph-inference fills memory_relationships edges nightly (cosine similarity ≥ 0.85).
|
|
627
|
+
5. TermDeck's Flashback will surface cross-project patterns automatically.
|
|
561
628
|
`);
|
|
562
629
|
}
|
|
563
630
|
|
|
@@ -635,7 +702,7 @@ async function main(argv) {
|
|
|
635
702
|
process.stderr.write(` ! falling back to pinned FALLBACK_RUMEN_VERSION=${FALLBACK_RUMEN_VERSION}\n`);
|
|
636
703
|
}
|
|
637
704
|
|
|
638
|
-
if (!
|
|
705
|
+
if (!deployFunctions(resolved.version, flags.dryRun)) return 6;
|
|
639
706
|
if (!setFunctionSecrets(secrets, flags.dryRun)) return 7;
|
|
640
707
|
if (!(await testFunction(projectRef, secrets, flags.dryRun))) return 8;
|
|
641
708
|
if (!flags.skipSchedule) {
|
|
@@ -671,3 +738,6 @@ module.exports = main;
|
|
|
671
738
|
// pin the access-token detection without spawning a real `supabase` binary.
|
|
672
739
|
module.exports._looksLikeMissingAccessToken = looksLikeMissingAccessToken;
|
|
673
740
|
module.exports._wireAccessTokenInMcpJson = wireAccessTokenInMcpJson;
|
|
741
|
+
// Sprint 43 T3: stage helper exposed so init-rumen-deploy.test.js can pin
|
|
742
|
+
// the multi-function staging contract without shelling out to `supabase`.
|
|
743
|
+
module.exports._stageRumenFunctions = stageRumenFunctions;
|
|
@@ -91,6 +91,65 @@
|
|
|
91
91
|
setupGuideRail();
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// ===== Drag/drop reorder of PTY panels (Sprint 42 T4) =====
|
|
95
|
+
// The grip handle in panel-header-left flips draggable=true on mousedown
|
|
96
|
+
// so an accidental drag inside the xterm region never fires. Drop
|
|
97
|
+
// position is determined by cursor x within the target panel — left half
|
|
98
|
+
// inserts before, right half inserts after — so reordering matches the
|
|
99
|
+
// intent in any grid layout (1x2, 2x2, 2x4, etc.). DOM reorder only;
|
|
100
|
+
// session.creation-order remains canonical for Alt+1…9 and panel-index.
|
|
101
|
+
function setupPanelDragDrop(panel) {
|
|
102
|
+
const handle = panel.querySelector('.panel-drag-handle');
|
|
103
|
+
if (!handle) return;
|
|
104
|
+
|
|
105
|
+
handle.addEventListener('mousedown', () => { panel.draggable = true; });
|
|
106
|
+
// Mouse leaves handle without a drag starting → reset
|
|
107
|
+
handle.addEventListener('mouseleave', () => {
|
|
108
|
+
if (!panel.classList.contains('dragging')) panel.draggable = false;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
panel.addEventListener('dragstart', (e) => {
|
|
112
|
+
if (!panel.draggable) { e.preventDefault(); return; }
|
|
113
|
+
try { e.dataTransfer.effectAllowed = 'move'; } catch (_e) {}
|
|
114
|
+
try { e.dataTransfer.setData('text/plain', panel.id); } catch (_e) {}
|
|
115
|
+
panel.classList.add('dragging');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
panel.addEventListener('dragend', () => {
|
|
119
|
+
panel.classList.remove('dragging');
|
|
120
|
+
panel.draggable = false;
|
|
121
|
+
document.querySelectorAll('.term-panel.drag-over').forEach((p) => p.classList.remove('drag-over'));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
panel.addEventListener('dragover', (e) => {
|
|
125
|
+
const dragging = document.querySelector('.term-panel.dragging');
|
|
126
|
+
if (!dragging || dragging === panel) return;
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
try { e.dataTransfer.dropEffect = 'move'; } catch (_e) {}
|
|
129
|
+
panel.classList.add('drag-over');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
panel.addEventListener('dragleave', (e) => {
|
|
133
|
+
// Only clear when leaving the panel entirely (not entering a child).
|
|
134
|
+
if (!panel.contains(e.relatedTarget)) panel.classList.remove('drag-over');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
panel.addEventListener('drop', (e) => {
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
panel.classList.remove('drag-over');
|
|
140
|
+
const draggedId = (() => {
|
|
141
|
+
try { return e.dataTransfer.getData('text/plain'); } catch (_e) { return ''; }
|
|
142
|
+
})();
|
|
143
|
+
const dragged = draggedId
|
|
144
|
+
? document.getElementById(draggedId)
|
|
145
|
+
: document.querySelector('.term-panel.dragging');
|
|
146
|
+
if (!dragged || dragged === panel) return;
|
|
147
|
+
const rect = panel.getBoundingClientRect();
|
|
148
|
+
const dropAfter = (e.clientX - rect.left) > rect.width / 2;
|
|
149
|
+
panel.parentNode.insertBefore(dragged, dropAfter ? panel.nextSibling : panel);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
94
153
|
// ===== Create Terminal Panel =====
|
|
95
154
|
function createTerminalPanel(sessionData) {
|
|
96
155
|
const id = sessionData.id;
|
|
@@ -128,6 +187,7 @@
|
|
|
128
187
|
panel.innerHTML = `
|
|
129
188
|
<div class="panel-header">
|
|
130
189
|
<div class="panel-header-left">
|
|
190
|
+
<span class="panel-drag-handle" title="Drag to reorder">⋮⋮</span>
|
|
131
191
|
<span class="status-dot" id="dot-${id}" style="background:${getStatusColor(meta.status)}"></span>
|
|
132
192
|
<span class="panel-type">${getTypeLabel(meta.type)}</span>
|
|
133
193
|
${meta.project ? `<span class="panel-project ${projClass}">${meta.project}</span>` : ''}
|
|
@@ -193,6 +253,11 @@
|
|
|
193
253
|
|
|
194
254
|
document.getElementById('termGrid').appendChild(panel);
|
|
195
255
|
|
|
256
|
+
// Sprint 42 T4: drag/drop reorder. Inject identifier is the session
|
|
257
|
+
// UUID, so DOM reorder is purely visual — Alt+1…9 (creation-order),
|
|
258
|
+
// /api/sessions/:id/input, and reply-form targets are unaffected.
|
|
259
|
+
setupPanelDragDrop(panel);
|
|
260
|
+
|
|
196
261
|
// Create xterm.js instance
|
|
197
262
|
const terminal = new Terminal({
|
|
198
263
|
fontFamily: "'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', Consolas, monospace",
|
|
@@ -238,7 +303,7 @@
|
|
|
238
303
|
updatePanelMeta(id, msg.session.meta);
|
|
239
304
|
break;
|
|
240
305
|
case 'proactive_memory':
|
|
241
|
-
showProactiveToast(id, msg.hit);
|
|
306
|
+
showProactiveToast(id, msg.hit, msg.flashback_event_id);
|
|
242
307
|
break;
|
|
243
308
|
case 'exit':
|
|
244
309
|
updatePanelMeta(id, {
|
|
@@ -265,6 +330,15 @@
|
|
|
265
330
|
if (typeof updateRagIndicator === 'function') updateRagIndicator();
|
|
266
331
|
}
|
|
267
332
|
break;
|
|
333
|
+
case 'projects_changed':
|
|
334
|
+
// Sprint 42 T4: server broadcasts on POST/DELETE /api/projects.
|
|
335
|
+
// Sync the in-memory projects map and re-render the dropdown so
|
|
336
|
+
// other open dashboard tabs stay consistent without a refresh.
|
|
337
|
+
if (msg.projects && state.config) {
|
|
338
|
+
state.config.projects = msg.projects;
|
|
339
|
+
if (typeof rebuildProjectDropdown === 'function') rebuildProjectDropdown();
|
|
340
|
+
}
|
|
341
|
+
break;
|
|
268
342
|
}
|
|
269
343
|
} catch (err) { console.error('[client] ws message parse failed:', err); }
|
|
270
344
|
};
|
|
@@ -509,7 +583,7 @@
|
|
|
509
583
|
}
|
|
510
584
|
}
|
|
511
585
|
|
|
512
|
-
function showProactiveToast(id, hit) {
|
|
586
|
+
function showProactiveToast(id, hit, flashbackEventId) {
|
|
513
587
|
const entry = state.sessions.get(id);
|
|
514
588
|
if (!entry || !entry.el) return;
|
|
515
589
|
|
|
@@ -532,16 +606,29 @@
|
|
|
532
606
|
|
|
533
607
|
entry.el.appendChild(toast);
|
|
534
608
|
|
|
609
|
+
// Sprint 43 T2: track dismiss/click-through against flashback_events.
|
|
610
|
+
// The id is set server-side in the proactive_memory WS frame; if it's
|
|
611
|
+
// missing (server-side INSERT failed, or older server) the POSTs are
|
|
612
|
+
// skipped and the live toast still works — persistence is best-effort.
|
|
535
613
|
const dismiss = () => {
|
|
536
614
|
toast.remove();
|
|
537
615
|
clearTimeout(toast._autoTimer);
|
|
616
|
+
if (flashbackEventId) {
|
|
617
|
+
fetch(`${API}/api/flashback/${flashbackEventId}/dismissed`, { method: 'POST' })
|
|
618
|
+
.catch((err) => console.warn('[flashback] dismiss POST failed:', err.message));
|
|
619
|
+
}
|
|
538
620
|
};
|
|
539
621
|
toast.querySelector('.t-dismiss').addEventListener('click', (e) => {
|
|
540
622
|
e.stopPropagation();
|
|
541
623
|
dismiss();
|
|
542
624
|
});
|
|
543
625
|
toast.addEventListener('click', () => {
|
|
544
|
-
|
|
626
|
+
toast.remove();
|
|
627
|
+
clearTimeout(toast._autoTimer);
|
|
628
|
+
if (flashbackEventId) {
|
|
629
|
+
fetch(`${API}/api/flashback/${flashbackEventId}/clicked`, { method: 'POST' })
|
|
630
|
+
.catch((err) => console.warn('[flashback] clicked POST failed:', err.message));
|
|
631
|
+
}
|
|
545
632
|
showFlashbackModal(hit, id);
|
|
546
633
|
});
|
|
547
634
|
|
|
@@ -1260,7 +1347,7 @@
|
|
|
1260
1347
|
updatePanelMeta(id, msg.session.meta);
|
|
1261
1348
|
break;
|
|
1262
1349
|
case 'proactive_memory':
|
|
1263
|
-
showProactiveToast(id, msg.hit);
|
|
1350
|
+
showProactiveToast(id, msg.hit, msg.flashback_event_id);
|
|
1264
1351
|
break;
|
|
1265
1352
|
case 'exit':
|
|
1266
1353
|
updatePanelMeta(id, { status: 'exited', statusDetail: `Exited (${msg.exitCode})` });
|
|
@@ -1281,6 +1368,14 @@
|
|
|
1281
1368
|
if (typeof updateRagIndicator === 'function') updateRagIndicator();
|
|
1282
1369
|
}
|
|
1283
1370
|
break;
|
|
1371
|
+
case 'projects_changed':
|
|
1372
|
+
// Sprint 42 T4: parity with main WS handler. Project add/remove
|
|
1373
|
+
// broadcasts arrive on every ws client; idempotent.
|
|
1374
|
+
if (msg.projects && state.config) {
|
|
1375
|
+
state.config.projects = msg.projects;
|
|
1376
|
+
if (typeof rebuildProjectDropdown === 'function') rebuildProjectDropdown();
|
|
1377
|
+
}
|
|
1378
|
+
break;
|
|
1284
1379
|
}
|
|
1285
1380
|
} catch (err) { console.error('[client] reconnect ws message failed:', err); }
|
|
1286
1381
|
};
|
|
@@ -1552,6 +1647,104 @@
|
|
|
1552
1647
|
}
|
|
1553
1648
|
}
|
|
1554
1649
|
|
|
1650
|
+
// ===== Remove Project modal (Sprint 42 T4) =====
|
|
1651
|
+
// Removes a project from ~/.termdeck/config.yaml. Files on disk at the
|
|
1652
|
+
// project's `path` are NEVER touched — the modal copy makes that explicit
|
|
1653
|
+
// so users don't fear data loss. 409 from the server (live PTY sessions
|
|
1654
|
+
// for that project) prompts the user with a force-override.
|
|
1655
|
+
function openRemoveProjectModal() {
|
|
1656
|
+
const modal = document.getElementById('removeProjectModal');
|
|
1657
|
+
const sel = document.getElementById('rpmSelect');
|
|
1658
|
+
sel.innerHTML = '<option value="">— pick a project —</option>';
|
|
1659
|
+
for (const name of Object.keys(state.config.projects || {})) {
|
|
1660
|
+
const opt = document.createElement('option');
|
|
1661
|
+
opt.value = name;
|
|
1662
|
+
opt.textContent = name;
|
|
1663
|
+
sel.appendChild(opt);
|
|
1664
|
+
}
|
|
1665
|
+
sel.value = '';
|
|
1666
|
+
document.getElementById('rpmConfirm').disabled = true;
|
|
1667
|
+
document.getElementById('rpmConfirm').dataset.force = '';
|
|
1668
|
+
document.getElementById('rpmConfirm').textContent = 'remove project';
|
|
1669
|
+
const warn = document.getElementById('rpmWarning');
|
|
1670
|
+
warn.hidden = true;
|
|
1671
|
+
warn.textContent = '';
|
|
1672
|
+
setRpmStatus('', null);
|
|
1673
|
+
modal.classList.add('open');
|
|
1674
|
+
setTimeout(() => sel.focus(), 50);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function closeRemoveProjectModal() {
|
|
1678
|
+
document.getElementById('removeProjectModal').classList.remove('open');
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
function setRpmStatus(msg, kind) {
|
|
1682
|
+
const el = document.getElementById('rpmStatus');
|
|
1683
|
+
if (!el) return;
|
|
1684
|
+
el.textContent = msg || '';
|
|
1685
|
+
el.classList.remove('error', 'ok');
|
|
1686
|
+
if (kind) el.classList.add(kind);
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
function onRpmSelectChange() {
|
|
1690
|
+
const name = document.getElementById('rpmSelect').value;
|
|
1691
|
+
const btn = document.getElementById('rpmConfirm');
|
|
1692
|
+
btn.disabled = !name;
|
|
1693
|
+
btn.dataset.force = '';
|
|
1694
|
+
btn.textContent = name ? `remove "${name}"` : 'remove project';
|
|
1695
|
+
const warn = document.getElementById('rpmWarning');
|
|
1696
|
+
warn.hidden = true;
|
|
1697
|
+
warn.textContent = '';
|
|
1698
|
+
setRpmStatus('', null);
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
async function submitRemoveProject() {
|
|
1702
|
+
const name = document.getElementById('rpmSelect').value;
|
|
1703
|
+
if (!name) return;
|
|
1704
|
+
const btn = document.getElementById('rpmConfirm');
|
|
1705
|
+
const force = btn.dataset.force === 'true';
|
|
1706
|
+
btn.disabled = true;
|
|
1707
|
+
setRpmStatus(force ? 'Removing (with force)…' : 'Removing…', null);
|
|
1708
|
+
|
|
1709
|
+
try {
|
|
1710
|
+
const url = `${API}/api/projects/${encodeURIComponent(name)}${force ? '?force=true' : ''}`;
|
|
1711
|
+
const res = await fetch(url, { method: 'DELETE' });
|
|
1712
|
+
const text = await res.text();
|
|
1713
|
+
let body = {};
|
|
1714
|
+
try { body = JSON.parse(text); } catch { body = { error: text }; }
|
|
1715
|
+
|
|
1716
|
+
if (res.status === 409) {
|
|
1717
|
+
const live = body.liveSessions || 0;
|
|
1718
|
+
const warn = document.getElementById('rpmWarning');
|
|
1719
|
+
warn.hidden = false;
|
|
1720
|
+
warn.innerHTML =
|
|
1721
|
+
`<strong>"${name}" has ${live} live PTY session${live === 1 ? '' : 's'}.</strong> ` +
|
|
1722
|
+
`Closing those terminals first is recommended. ` +
|
|
1723
|
+
`Or click <em>remove anyway</em> to force removal — terminals stay open but lose their project tag in config.yaml.`;
|
|
1724
|
+
btn.dataset.force = 'true';
|
|
1725
|
+
btn.textContent = 'remove anyway';
|
|
1726
|
+
btn.disabled = false;
|
|
1727
|
+
setRpmStatus('', null);
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
if (!res.ok) {
|
|
1732
|
+
setRpmStatus(`Failed: ${body.error || res.statusText}`, 'error');
|
|
1733
|
+
btn.disabled = false;
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// Success — sync in-memory config + dropdown.
|
|
1738
|
+
state.config.projects = body.projects || {};
|
|
1739
|
+
rebuildProjectDropdown();
|
|
1740
|
+
setRpmStatus(`Removed "${name}" ✓ (files on disk untouched)`, 'ok');
|
|
1741
|
+
setTimeout(() => { closeRemoveProjectModal(); }, 900);
|
|
1742
|
+
} catch (err) {
|
|
1743
|
+
setRpmStatus(`Failed: ${err.message || err}`, 'error');
|
|
1744
|
+
btn.disabled = false;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1555
1748
|
// ===== Orchestration preview modal (Sprint 37 T3) =====
|
|
1556
1749
|
// The preview button next to the project select shows what
|
|
1557
1750
|
// `termdeck init --project <name>` would create for the currently
|
|
@@ -3714,6 +3907,16 @@
|
|
|
3714
3907
|
if (e.key === 'Escape') { e.preventDefault(); closeAddProjectModal(); }
|
|
3715
3908
|
});
|
|
3716
3909
|
|
|
3910
|
+
// Remove-project modal wiring (Sprint 42 T4)
|
|
3911
|
+
document.getElementById('btnRemoveProject').addEventListener('click', openRemoveProjectModal);
|
|
3912
|
+
document.getElementById('rpmCancel').addEventListener('click', closeRemoveProjectModal);
|
|
3913
|
+
document.getElementById('rpmConfirm').addEventListener('click', submitRemoveProject);
|
|
3914
|
+
document.getElementById('rpmSelect').addEventListener('change', onRpmSelectChange);
|
|
3915
|
+
document.querySelector('#removeProjectModal .remove-project-backdrop').addEventListener('click', closeRemoveProjectModal);
|
|
3916
|
+
document.getElementById('removeProjectModal').addEventListener('keydown', (e) => {
|
|
3917
|
+
if (e.key === 'Escape') { e.preventDefault(); closeRemoveProjectModal(); }
|
|
3918
|
+
});
|
|
3919
|
+
|
|
3717
3920
|
// Orchestration preview modal wiring (Sprint 37 T3)
|
|
3718
3921
|
document.getElementById('btnPreviewProject').addEventListener('click', openPreviewModal);
|
|
3719
3922
|
document.getElementById('promptProject').addEventListener('change', syncPreviewButton);
|