|
| 1 | +import chalk from 'chalk'; |
| 2 | + |
1 | 3 | import { |
2 | 4 | EdgeRecord, |
3 | 5 | deleteSuggestion, |
@@ -385,6 +387,15 @@ async function runEdgesList(flags: EdgesListFlags) { |
385 | 387 | }); |
386 | 388 | } |
387 | 389 |
|
| 390 | +/** |
| 391 | + * Format score component as 2-digit integer (0-99 scale) |
| 392 | + */ |
| 393 | +function formatScoreComponent(value: number): string { |
| 394 | + const scaled = Math.floor(value * 100); |
| 395 | + const clamped = Math.max(0, Math.min(99, scaled)); |
| 396 | + return clamped.toString().padStart(2, '0'); |
| 397 | +} |
| 398 | + |
388 | 399 | async function runEdgesPropose(flags: EdgesProposeFlags) { |
389 | 400 | const limit = |
390 | 401 | typeof flags.limit === 'number' && !Number.isNaN(flags.limit) && flags.limit > 0 ? flags.limit : 10; |
@@ -424,19 +435,113 @@ async function runEdgesPropose(flags: EdgesProposeFlags) { |
424 | 435 | return; |
425 | 436 | } |
426 | 437 |
|
427 | | - console.log('Suggested edges (ordered by score):'); |
428 | | - edges.forEach((edge, index) => { |
429 | | - const desc = describeSuggestion(edge, nodeMap, { longIds, allEdges }); |
430 | | - const indexLabel = String(index + 1).padStart(2, ' '); |
431 | | - console.log( |
432 | | - `${indexLabel}. [${desc.code}] ${desc.edgeId} score=${edge.score.toFixed(3)} ${desc.sourceLabel} ↔ ${desc.targetLabel}`, |
433 | | - ); |
434 | | - }); |
| 438 | + // New header format |
| 439 | + console.log('Top identified links sorted by aggregate score.'); |
| 440 | + console.log('/forest edges accept/reject [ref]'); |
435 | 441 | console.log(''); |
436 | | - console.log('To accept or reject suggestions:'); |
437 | | - console.log(' forest edges accept <ref> # ref can be index (1), code (0L5a), or short pair'); |
438 | | - console.log(' forest edges reject <ref>'); |
439 | | - console.log(' forest edges promote # bulk accept above threshold'); |
| 442 | + |
| 443 | + // Helper to convert HSL to RGB (used for headers) |
| 444 | + const hslToRgb = (h: number, s: number, l: number): [number, number, number] => { |
| 445 | + s /= 100; |
| 446 | + l /= 100; |
| 447 | + const k = (n: number) => (n + h / 30) % 12; |
| 448 | + const a = s * Math.min(l, 1 - l); |
| 449 | + const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); |
| 450 | + return [Math.round(255 * f(0)), Math.round(255 * f(8)), Math.round(255 * f(4))]; |
| 451 | + }; |
| 452 | + |
| 453 | + // Header styling |
| 454 | + const makeHeaderColor = (hue: number) => { |
| 455 | + const [r, g, b] = hslToRgb(hue + 10, 45, 40); // 40% brightness, hue twist +10° |
| 456 | + return chalk.rgb(r, g, b); |
| 457 | + }; |
| 458 | + |
| 459 | + const headerLine = |
| 460 | + '◌ ' + |
| 461 | + chalk.grey('ref') + ' ' + |
| 462 | + makeHeaderColor(35)('ag') + ' ' + |
| 463 | + makeHeaderColor(120)('em') + ' ' + |
| 464 | + makeHeaderColor(100)('tk') + ' ' + |
| 465 | + makeHeaderColor(45)('ti') + ' ' + |
| 466 | + makeHeaderColor(10)('tg') + ' ' + |
| 467 | + chalk.hex('#A8C5DD')('nodeA') + |
| 468 | + chalk.grey('::') + |
| 469 | + chalk.hex('#A8DDB5')('nodeB'); |
| 470 | + |
| 471 | + console.log(headerLine); |
| 472 | + |
| 473 | + // Cap maximum display width to prevent excessive gaps on wide terminals |
| 474 | + const MAX_DISPLAY_WIDTH = 100; |
| 475 | + const terminalWidth = Math.min(process.stdout.columns || 120, MAX_DISPLAY_WIDTH); |
| 476 | + |
| 477 | + // Fixed column widths for consistent alignment (fits perfectly under nodeA::nodeB) |
| 478 | + const TITLE_A_WIDTH = 29; // Fixed width for titleA |
| 479 | + const TITLE_B_WIDTH = 29; // Fixed width for titleB |
| 480 | + |
| 481 | + // Helper to colorize score value based on magnitude (gradient heat map) |
| 482 | + const colorizeScore = (value: number, hue: number): string => { |
| 483 | + const formattedValue = formatScoreComponent(value); |
| 484 | + |
| 485 | + // Gradient from dark (low) to bright (high) |
| 486 | + // HSL interpolation: consistent hue, varying lightness & saturation |
| 487 | + const t = Math.max(0, Math.min(1, value)); // Clamp to [0, 1] |
| 488 | + |
| 489 | + // Dark muted -> bright vibrant |
| 490 | + const lightness = 20 + t * 50; // 20% to 70% |
| 491 | + const saturation = 25 + t * 45; // 25% to 70% |
| 492 | + |
| 493 | + const [r, g, b] = hslToRgb(hue, saturation, lightness); |
| 494 | + return chalk.rgb(r, g, b)(formattedValue); |
| 495 | + }; |
| 496 | + |
| 497 | + // Helper to color node IDs with subtle per-character hue variation |
| 498 | + const colorNodeId = (id: string): string => { |
| 499 | + return id |
| 500 | + .split('') |
| 501 | + .map((char, i) => { |
| 502 | + const hueOffset = (i * 13) % 30; // Subtle variation |
| 503 | + const [r, g, b] = hslToRgb(200 + hueOffset, 10, 65); // Light grey with slight hue shift |
| 504 | + return chalk.rgb(r, g, b)(char); |
| 505 | + }) |
| 506 | + .join(''); |
| 507 | + }; |
| 508 | + |
| 509 | + edges.forEach((edge) => { |
| 510 | + const nodeA = nodeMap.get(edge.sourceId); |
| 511 | + const nodeB = nodeMap.get(edge.targetId); |
| 512 | + if (!nodeA || !nodeB) return; |
| 513 | + |
| 514 | + // Get or compute scoring components |
| 515 | + const components = (edge.metadata as any)?.components ?? computeScore(nodeA, nodeB).components; |
| 516 | + |
| 517 | + // Format score columns with distinct forest hues |
| 518 | + const ag = colorizeScore(edge.score, 35); // Amber/bark brown |
| 519 | + const em = colorizeScore(components.embeddingSimilarity, 120); // Forest green |
| 520 | + const tk = colorizeScore(components.tokenSimilarity, 100); // Moss/lime green |
| 521 | + const ti = colorizeScore(components.titleSimilarity, 45); // Autumn gold/yellow |
| 522 | + const tg = colorizeScore(components.tagOverlap, 10); // Clay red/rust |
| 523 | + |
| 524 | + // Get edge code and pad to 5 chars - color orange, no ◌ prefix |
| 525 | + const code = getEdgePrefix(edge.sourceId, edge.targetId, allEdges).padEnd(5, ' '); |
| 526 | + const coloredCode = chalk.hex('#FF8C00')(code); |
| 527 | + |
| 528 | + // Simple fixed-width truncation (no balancing complexity) |
| 529 | + const truncA = nodeA.title.length > TITLE_A_WIDTH |
| 530 | + ? nodeA.title.slice(0, TITLE_A_WIDTH - 1) + '…' |
| 531 | + : nodeA.title; |
| 532 | + const truncB = nodeB.title.length > TITLE_B_WIDTH |
| 533 | + ? nodeB.title.slice(0, TITLE_B_WIDTH - 1) + '…' |
| 534 | + : nodeB.title; |
| 535 | + |
| 536 | + // Pad titleA to fixed width for perfect :: alignment |
| 537 | + const titleAPadded = truncA.padEnd(TITLE_A_WIDTH, ' '); |
| 538 | + |
| 539 | + // Format node IDs with subtle color variation |
| 540 | + const idA = colorNodeId(formatId(edge.sourceId)); |
| 541 | + const idB = colorNodeId(formatId(edge.targetId)); |
| 542 | + |
| 543 | + console.log(`${coloredCode} ${ag} ${em} ${tk} ${ti} ${tg} ${titleAPadded} ${idA}${chalk.grey('::')}${idB} ${truncB}`); |
| 544 | + }); |
440 | 545 | } |
441 | 546 |
|
442 | 547 | async function runEdgesPromote(flags: EdgesPromoteFlags) { |
|
0 commit comments