ccakashic 0.3.1 → 0.4.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/README.md CHANGED
@@ -36,6 +36,10 @@ A local HTTP server starts and your browser opens automatically.
36
36
  - **Waiting-for-you indicator** — Sessions cmux is notifying you about (an **unread** "Claude is waiting for your input" / "needs your permission") get an orange frame and a `⏳ Your turn` / `🔐 Permission` badge. cmux marks the notification read the moment you focus that workspace, so the highlight **self-clears** on the next poll once you open the tab — it mirrors cmux's own badge exactly. The browser tab title shows the count (`(2) ccakashic`) and the favicon turns orange, so a glance at the tab tells you how many sessions need you. (Requires cmux; covers sessions resumed through ccakashic, which are tracked in the resume map.)
37
37
  - **Fully browser-based** — Dashboard → Project list → Session list → Conversation detail
38
38
  - **Chat-style layout** — User / assistant messages in chat bubbles
39
+ - **Show only the conversation** — A `Show` row in the session header toggles each kind of noise off: `Tools`, `Injected`, `Thinking`, `Shell`, `System`, `Cost`. `Chat only` strips a session down to what was asked and answered; the choice is remembered across sessions
40
+ - **Real prompts vs. injected text** — A `user` record in the log is not necessarily something you typed: hook feedback, skill bodies, task notifications and compaction summaries are all fed to the model in the user role. Those are labelled (`HOOK FEEDBACK`, `SKILL`, `TASK NOTIFICATION`, …) and collapsed into their own row instead of sharing your chat bubble
41
+ - **Jump between your own prompts** — A pager in the corner (`▲ 11 / 31 ▼`, or `p` / `n`) moves through the prompts you actually typed and tracks where you are as you scroll
42
+ - **Sticky session header** — Title, branch, model, Resume buttons and the filters stay on screen, condensing to a thin strip as you scroll
39
43
  - **Collapsible tool calls** — Bash, Read, Edit, and other tool invocations collapsed by default
40
44
  - **Diff view** — File edits shown with red/green line highlights
41
45
  - **Date navigation** — Side nav and sticky headers to jump between dates
@@ -47,7 +51,7 @@ A local HTTP server starts and your browser opens automatically.
47
51
  - **Session-level stats** — Estimated cost, turns, token breakdown, cache hit rate, and duration in the header
48
52
  - **Dark mode** — Follows `prefers-color-scheme` automatically
49
53
  - **Filter search** — Incremental filtering on list pages
50
- - **Keyboard navigation** — `j` / `k` to move between messages
54
+ - **Keyboard navigation** — `j` / `k` to move between messages, `p` / `n` to move between your own prompts
51
55
  - **One-click resume in cmux** — `▶ Resume` spawns a [cmux](https://github.com/manaflow-ai/cmux) workspace that runs `cd <session cwd> && claude --resume <id>`; `📋 Copy` copies the same command for any terminal
52
56
  - **Zero dependencies** — Node.js built-in modules only
53
57
 
@@ -112,6 +112,10 @@ function renderDiff(patches) {
112
112
  lines.push('</div>');
113
113
  return lines.join('\n');
114
114
  }
115
+ function firstLine(text, max) {
116
+ const flat = (text || '').replace(/\s+/g, ' ').trim();
117
+ return flat.length > max ? flat.slice(0, max) + '…' : flat;
118
+ }
115
119
  function msgId(ts) {
116
120
  if (!ts)
117
121
  return `msg-${Date.now()}${Math.random().toString(36).slice(2, 5)}`;
@@ -151,6 +155,14 @@ function renderMessage(msg) {
151
155
  const itemBadge = makeItemBadge(msg);
152
156
  switch (msg.type) {
153
157
  case 'user':
158
+ // Text the harness injected in the user role (hook output, skill bodies,
159
+ // task notifications, …) is not part of the conversation, so it gets a
160
+ // collapsed row instead of the user's bubble.
161
+ if (msg.injected) {
162
+ const label = (0, util_1.escapeHtml)(msg.injectedKind || 'Injected');
163
+ const peek = (0, util_1.escapeHtml)(firstLine(msg.text, 120));
164
+ return `<div class="msg msg-injected" id="${id}"><details><summary>${time}<span class="injected-label">${label}</span><span class="injected-peek">${peek}</span></summary><div class="injected-body" data-markdown>${(0, util_1.escapeHtml)(msg.text)}</div></details>${turnBadge ? `<div class="tool-usage-row">${turnBadge}</div>` : ''}</div>`;
165
+ }
154
166
  return `<div class="msg msg-user" id="${id}">${time}<div class="msg-content" data-markdown>${(0, util_1.escapeHtml)(msg.text)}</div>${turnBadge}</div>`;
155
167
  case 'assistant':
156
168
  return `<div class="msg msg-assistant" id="${id}">${time}<div class="msg-content" data-markdown>${(0, util_1.escapeHtml)(msg.text)}</div>${itemBadge ? `<div>${itemBadge}</div>` : ''}</div>`;
@@ -294,6 +306,27 @@ function renderStats(stats) {
294
306
  }
295
307
  return `<div class="stats-bar">${items.join('')}</div>`;
296
308
  }
309
+ // Toggles for the noisier parts of a thread. The chat itself (user +
310
+ // assistant) is never filtered — these only hide the surrounding machinery, so
311
+ // a reader who mostly wants the conversation can strip it down. Choices are
312
+ // persisted client-side (localStorage) so they survive navigation.
313
+ const MESSAGE_FILTERS = [
314
+ { key: 'tools', label: 'Tools', title: 'Tool calls, their output, and inlined subagent conversations' },
315
+ { key: 'injected', label: 'Injected', title: 'Text fed to the model in the user role: hook output, skill bodies, task notifications, compaction summaries' },
316
+ { key: 'thinking', label: 'Thinking', title: 'Thinking indicators' },
317
+ { key: 'shell', label: 'Shell', title: 'Local ! commands and their output' },
318
+ { key: 'system', label: 'System', title: 'System messages' },
319
+ { key: 'cost', label: 'Cost', title: 'Token and cost badges' },
320
+ ];
321
+ function filterBarHtml() {
322
+ const chips = MESSAGE_FILTERS.map(f => `<label class="filter-chip" title="${(0, util_1.escapeHtml)(f.title)}"><input type="checkbox" data-filter="${f.key}" checked>${(0, util_1.escapeHtml)(f.label)}</label>`).join('');
323
+ return `<div class="detail-filters" id="detailFilters">
324
+ <span class="detail-filters-label">Show</span>
325
+ ${chips}
326
+ <button type="button" class="filter-preset-btn" data-preset="chat" title="Hide everything except the conversation">Chat only</button>
327
+ <button type="button" class="filter-preset-btn" data-preset="all" title="Show everything">Show all</button>
328
+ </div>`;
329
+ }
297
330
  function generate(parsed, options = {}) {
298
331
  const { projectName, projectRawName, session, backUrl, resume } = options;
299
332
  const resumeButtons = session?.id && projectRawName
@@ -318,7 +351,7 @@ function generate(parsed, options = {}) {
318
351
  .filter(g => g.date !== 'unknown')
319
352
  .map(g => `<a class="detail-sidenav-item" href="#date-${g.date}" data-date="${(0, util_1.escapeHtml)(g.date)}">${(0, util_1.escapeHtml)(g.date)}</a>`).join('\n');
320
353
  const backLink = backUrl
321
- ? `<div style="font-size:0.8rem;margin-bottom:8px"><a href="${(0, util_1.escapeHtml)(backUrl)}" style="color:var(--link);text-decoration:none">&larr; Back to sessions</a> &nbsp;|&nbsp; <a href="/" style="color:var(--link);text-decoration:none">Dashboard</a> &nbsp;|&nbsp; <a href="/projects" style="color:var(--link);text-decoration:none">All projects</a></div>`
354
+ ? `<div class="detail-backlink"><a href="${(0, util_1.escapeHtml)(backUrl)}">&larr; Back to sessions</a> &nbsp;|&nbsp; <a href="/">Dashboard</a> &nbsp;|&nbsp; <a href="/projects">All projects</a></div>`
322
355
  : '';
323
356
  return `<!DOCTYPE html>
324
357
  <html lang="en">
@@ -333,7 +366,7 @@ ${(0, resume_ui_1.resumeCSS)()}
333
366
  </head>
334
367
  <body>
335
368
  <a href="https://github.com/ashimon83/ccakashic" class="github-corner" aria-label="View source on GitHub" target="_blank" rel="noopener"><svg width="70" height="70" viewBox="0 0 250 250" aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a>
336
- <div class="detail-sticky-bar" id="detailStickyBar"></div>
369
+ <div class="session-header-bar" id="sessionHeaderBar">
337
370
  <header class="session-header">
338
371
  ${backLink}
339
372
  <h1>${(0, util_1.escapeHtml)(title)}</h1>
@@ -345,7 +378,9 @@ ${(0, resume_ui_1.resumeCSS)()}
345
378
  </div>
346
379
  ${resumeButtons}
347
380
  ${renderStats(parsed.stats)}
381
+ ${filterBarHtml()}
348
382
  </header>
383
+ </div>
349
384
  <div class="detail-layout">
350
385
  <nav class="detail-sidenav" id="detailSidenav">
351
386
  <div class="detail-sidenav-title">Dates</div>
@@ -363,8 +398,15 @@ ${(0, resume_ui_1.resumeCSS)()}
363
398
  <div id="session-bottom"></div>
364
399
  </main>
365
400
  </div>
401
+ <div class="msg-pager" id="msgPager" hidden title="Jump between your own messages (p / n)">
402
+ <button type="button" class="msg-pager-btn" data-dir="-1" aria-label="Previous message of yours">&#9650;</button>
403
+ <span class="msg-pager-count" id="msgPagerCount">&ndash;</span>
404
+ <button type="button" class="msg-pager-btn" data-dir="1" aria-label="Next message of yours">&#9660;</button>
405
+ </div>
366
406
  <script>${(0, template_assets_1.getAppJS)()}
367
407
  ${detailNavJS()}
408
+ ${messageFilterJS()}
409
+ ${msgPagerJS()}
368
410
  ${(0, resume_ui_1.resumeJS)(resume)}
369
411
  </script>
370
412
  </body>
@@ -372,6 +414,242 @@ ${(0, resume_ui_1.resumeJS)(resume)}
372
414
  }
373
415
  function detailLayoutCSS() {
374
416
  return `
417
+ /* Sticky session header. The bar spans the full width (so nothing scrolls
418
+ past it at the edges) while the header inside keeps its centred column.
419
+ --header-h is kept in sync by JS and is what the date headings, the side
420
+ nav and anchor scrolling offset themselves against. */
421
+ .session-header-bar {
422
+ position: sticky;
423
+ top: 0;
424
+ z-index: 120;
425
+ background: var(--bg);
426
+ border-bottom: 1px solid var(--border);
427
+ }
428
+ .session-header-bar .session-header {
429
+ border-bottom: none;
430
+ padding: 20px 16px 12px;
431
+ }
432
+ /* Past the first scroll the header sheds its bulkier rows so it stays a thin
433
+ strip; the title, meta, resume buttons and filters remain reachable. */
434
+ .session-header-bar.is-condensed {
435
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.14);
436
+ }
437
+ .session-header-bar.is-condensed .session-header {
438
+ padding: 6px 16px 8px;
439
+ }
440
+ .session-header-bar.is-condensed .detail-backlink,
441
+ .session-header-bar.is-condensed .stats-bar {
442
+ display: none;
443
+ }
444
+ .session-header-bar.is-condensed h1 {
445
+ font-size: 0.95rem;
446
+ white-space: nowrap;
447
+ overflow: hidden;
448
+ text-overflow: ellipsis;
449
+ }
450
+ .session-header-bar.is-condensed .session-meta {
451
+ margin-top: 2px;
452
+ font-size: 0.75rem;
453
+ gap: 12px;
454
+ }
455
+ .session-header-bar.is-condensed .resume-actions,
456
+ .session-header-bar.is-condensed .detail-filters {
457
+ margin-top: 6px;
458
+ }
459
+
460
+ .detail-backlink {
461
+ font-size: 0.8rem;
462
+ margin-bottom: 8px;
463
+ }
464
+ .detail-backlink a {
465
+ color: var(--link);
466
+ text-decoration: none;
467
+ }
468
+ .detail-backlink a:hover { text-decoration: underline; }
469
+
470
+ /* Message-type filters */
471
+ .detail-filters {
472
+ display: flex;
473
+ flex-wrap: wrap;
474
+ align-items: center;
475
+ gap: 6px 8px;
476
+ margin-top: 10px;
477
+ font-size: 0.75rem;
478
+ color: var(--text-muted);
479
+ }
480
+ .detail-filters-label {
481
+ text-transform: uppercase;
482
+ letter-spacing: 0.06em;
483
+ font-weight: 600;
484
+ font-size: 0.65rem;
485
+ }
486
+ .filter-chip {
487
+ display: inline-flex;
488
+ align-items: center;
489
+ gap: 5px;
490
+ padding: 2px 10px;
491
+ border: 1px solid var(--border);
492
+ border-radius: 999px;
493
+ background: var(--bg-secondary);
494
+ color: var(--text);
495
+ cursor: pointer;
496
+ user-select: none;
497
+ transition: border-color 0.15s, opacity 0.15s;
498
+ }
499
+ .filter-chip:hover { border-color: var(--link); }
500
+ .filter-chip input {
501
+ margin: 0;
502
+ cursor: pointer;
503
+ accent-color: var(--link);
504
+ }
505
+ .filter-chip:has(input:not(:checked)) {
506
+ opacity: 0.5;
507
+ text-decoration: line-through;
508
+ }
509
+ .filter-preset-btn {
510
+ font-size: 0.7rem;
511
+ font-weight: 600;
512
+ padding: 3px 10px;
513
+ border-radius: 5px;
514
+ border: 1px solid var(--border);
515
+ background: var(--tool-bg);
516
+ color: var(--text);
517
+ cursor: pointer;
518
+ transition: background 0.15s, border-color 0.15s;
519
+ }
520
+ .filter-preset-btn:hover {
521
+ border-color: var(--link);
522
+ background: var(--bg-secondary);
523
+ }
524
+
525
+ /* Injected user-role text (hook output, skill bodies, notifications). Kept
526
+ full width and visually apart from the user's own bubble, and collapsed —
527
+ these run long and are read only when something looks off. */
528
+ .msg-injected {
529
+ align-self: center;
530
+ width: 100%;
531
+ max-width: 92%;
532
+ padding: 0;
533
+ background: var(--tool-bg);
534
+ border: 1px dashed var(--border);
535
+ border-radius: 8px;
536
+ overflow: hidden;
537
+ }
538
+ .msg-injected summary {
539
+ padding: 8px 14px;
540
+ cursor: pointer;
541
+ font-size: 0.8rem;
542
+ color: var(--text-muted);
543
+ display: flex;
544
+ align-items: center;
545
+ gap: 8px;
546
+ user-select: none;
547
+ list-style: none;
548
+ transition: background 0.15s;
549
+ }
550
+ .msg-injected summary::-webkit-details-marker { display: none; }
551
+ .msg-injected summary::before {
552
+ content: '\\25B6';
553
+ font-size: 0.6rem;
554
+ transition: transform 0.2s;
555
+ flex-shrink: 0;
556
+ }
557
+ .msg-injected details[open] > summary::before { transform: rotate(90deg); }
558
+ .msg-injected summary:hover { background: var(--bg-secondary); }
559
+ .msg-injected .timestamp { flex-shrink: 0; margin-bottom: 0; }
560
+ .injected-label {
561
+ flex-shrink: 0;
562
+ padding: 1px 8px;
563
+ border: 1px solid var(--border);
564
+ border-radius: 999px;
565
+ background: var(--bg-secondary);
566
+ font-size: 0.68rem;
567
+ font-weight: 600;
568
+ text-transform: uppercase;
569
+ letter-spacing: 0.04em;
570
+ }
571
+ .injected-peek {
572
+ overflow: hidden;
573
+ text-overflow: ellipsis;
574
+ white-space: nowrap;
575
+ opacity: 0.8;
576
+ }
577
+ .injected-body {
578
+ border-top: 1px dashed var(--border);
579
+ padding: 12px 14px;
580
+ font-size: 0.85rem;
581
+ color: var(--text-muted);
582
+ }
583
+
584
+ /* What each filter hides. User and assistant messages are never touched. */
585
+ body.hide-injected .msg-injected,
586
+ body.hide-tools .msg-tool,
587
+ body.hide-thinking .msg-thinking,
588
+ body.hide-shell .msg-local-cmd,
589
+ body.hide-system .msg-system {
590
+ display: none;
591
+ }
592
+ body.hide-cost .turn-usage,
593
+ body.hide-cost .item-usage,
594
+ body.hide-cost .tool-usage-row {
595
+ display: none;
596
+ }
597
+
598
+ /* Pager over the user's own prompts. Always on screen, because finding "what
599
+ did I actually ask here" is the main reason to open a long session. */
600
+ .msg-pager {
601
+ position: fixed;
602
+ right: 20px;
603
+ bottom: 20px;
604
+ z-index: 150;
605
+ display: flex;
606
+ align-items: center;
607
+ gap: 2px;
608
+ padding: 4px;
609
+ background: var(--bg-secondary);
610
+ border: 1px solid var(--border);
611
+ border-radius: 999px;
612
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
613
+ }
614
+ .msg-pager[hidden] { display: none; }
615
+ .msg-pager-btn {
616
+ width: 28px;
617
+ height: 28px;
618
+ display: flex;
619
+ align-items: center;
620
+ justify-content: center;
621
+ font-size: 0.6rem;
622
+ border: none;
623
+ border-radius: 50%;
624
+ background: transparent;
625
+ color: var(--text-muted);
626
+ cursor: pointer;
627
+ transition: background 0.15s, color 0.15s;
628
+ }
629
+ .msg-pager-btn:hover {
630
+ background: var(--user-bg);
631
+ color: var(--text);
632
+ }
633
+ .msg-pager-btn:disabled {
634
+ opacity: 0.3;
635
+ cursor: default;
636
+ background: transparent;
637
+ }
638
+ .msg-pager-count {
639
+ min-width: 48px;
640
+ text-align: center;
641
+ font-size: 0.72rem;
642
+ font-weight: 600;
643
+ font-variant-numeric: tabular-nums;
644
+ color: var(--text-muted);
645
+ user-select: none;
646
+ }
647
+
648
+ /* Anchors (timestamp links, date jumps) must clear the sticky header. */
649
+ .msg, .detail-date-group {
650
+ scroll-margin-top: calc(var(--header-h, 60px) + 44px);
651
+ }
652
+
375
653
  .detail-layout {
376
654
  display: flex;
377
655
  max-width: 1100px;
@@ -437,9 +715,9 @@ function detailLayoutCSS() {
437
715
  width: 140px;
438
716
  flex-shrink: 0;
439
717
  position: sticky;
440
- top: 48px;
718
+ top: calc(var(--header-h, 60px) + 8px);
441
719
  align-self: flex-start;
442
- max-height: calc(100vh - 60px);
720
+ max-height: calc(100vh - var(--header-h, 60px) - 20px);
443
721
  overflow-y: auto;
444
722
  padding: 16px 8px 16px 16px;
445
723
  border-right: 1px solid var(--border);
@@ -486,7 +764,7 @@ function detailLayoutCSS() {
486
764
  border-bottom: 2px solid var(--border);
487
765
  margin-bottom: 12px;
488
766
  position: sticky;
489
- top: 44px;
767
+ top: var(--header-h, 60px);
490
768
  background: var(--bg);
491
769
  z-index: 5;
492
770
  }
@@ -497,60 +775,241 @@ function detailLayoutCSS() {
497
775
  gap: 28px;
498
776
  }
499
777
 
500
- /* Sticky date bar */
501
- .detail-sticky-bar {
502
- position: fixed;
503
- top: 0;
504
- left: 0;
505
- right: 0;
506
- z-index: 100;
507
- background: var(--bg-secondary);
508
- border-bottom: 1px solid var(--border);
509
- padding: 8px 24px;
510
- font-size: 0.85rem;
511
- font-weight: 700;
512
- color: var(--text);
513
- transform: translateY(-100%);
514
- transition: transform 0.2s;
515
- }
516
- .detail-sticky-bar.visible {
517
- transform: translateY(0);
518
- }
519
-
520
778
  @media (max-width: 768px) {
521
779
  .detail-sidenav { display: none; }
522
780
  .detail-layout { display: block; }
781
+ /* On a phone the condensed header is competing with the thread for height;
782
+ the meta row wraps to two lines, so drop it and keep title + filters. */
783
+ .session-header-bar.is-condensed .session-meta { display: none; }
523
784
  }
524
785
  `;
525
786
  }
526
787
  function detailNavJS() {
527
788
  return `
528
789
  (function() {
790
+ var bar = document.getElementById('sessionHeaderBar');
529
791
  var groups = Array.from(document.querySelectorAll('.detail-date-group'));
530
- var bar = document.getElementById('detailStickyBar');
531
- var navItems = Array.from(document.querySelectorAll('.detail-sidenav-item'));
532
- if (!groups.length) return;
792
+ var navItems = Array.from(document.querySelectorAll('.detail-sidenav-item[data-date]'));
793
+
794
+ // Everything that has to clear the sticky header reads --header-h, so it has
795
+ // to follow the header through condensing, resizes and font/wrap changes.
796
+ function syncHeaderHeight() {
797
+ if (bar) document.documentElement.style.setProperty('--header-h', bar.offsetHeight + 'px');
798
+ }
799
+ syncHeaderHeight();
800
+ if (bar && window.ResizeObserver) new ResizeObserver(syncHeaderHeight).observe(bar);
801
+ window.addEventListener('resize', syncHeaderHeight);
802
+ window.ccakashicSyncHeaderHeight = syncHeaderHeight;
533
803
 
534
804
  function update() {
535
- var scrollY = window.scrollY + 60;
805
+ if (bar) {
806
+ // Hysteresis: condensing shortens the header, which nudges the scroll
807
+ // position — a single threshold would flip back and forth on it.
808
+ var condensed = bar.classList.contains('is-condensed');
809
+ var next = condensed ? window.scrollY > 24 : window.scrollY > 72;
810
+ if (next !== condensed) {
811
+ bar.classList.toggle('is-condensed', next);
812
+ syncHeaderHeight();
813
+ }
814
+ }
815
+ if (!groups.length) return;
816
+ var offset = window.scrollY + (bar ? bar.offsetHeight : 0) + 20;
536
817
  var current = null;
537
818
  for (var i = 0; i < groups.length; i++) {
538
- if (groups[i].offsetTop <= scrollY) current = groups[i];
819
+ if (groups[i].style.display !== 'none' && groups[i].offsetTop <= offset) current = groups[i];
539
820
  }
540
821
  var date = current ? current.dataset.date : '';
541
- if (date) {
542
- bar.textContent = date;
543
- bar.classList.add('visible');
544
- } else {
545
- bar.classList.remove('visible');
546
- }
547
822
  navItems.forEach(function(a) {
548
823
  a.classList.toggle('active', a.dataset.date === date);
549
824
  });
550
825
  }
551
826
 
827
+ // A sticky element keeps its box in the flow, so when the header condenses
828
+ // everything below it shifts up by the height it lost. A jump computed
829
+ // against the expanded layout therefore lands short — by the full delta when
830
+ // jumping from the top of the page. Nudge the target until it actually sits
831
+ // at its scroll-margin offset. Shared with the prompt pager below.
832
+ // update() runs first so the condense state and --header-h are settled before
833
+ // measuring: it normally reacts to the scroll event, which fires after this
834
+ // code would already have measured the stale layout. Looping synchronously
835
+ // (scrollBy applies immediately, and reading the rect forces layout) makes
836
+ // this converge in two or three passes instead of racing frames.
837
+ function align(el, tries) {
838
+ if (!el) return;
839
+ update();
840
+ var margin = parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
841
+ if (!margin) return;
842
+ var delta = el.getBoundingClientRect().top - margin;
843
+ if (Math.abs(delta) < 2 || tries > 5) return;
844
+ window.scrollBy(0, delta);
845
+ align(el, tries + 1);
846
+ }
847
+ window.ccakashicAlign = align;
848
+
849
+ // Same correction for the date links and timestamp anchors.
850
+ window.addEventListener('hashchange', function() {
851
+ if (!location.hash) return;
852
+ align(document.getElementById(location.hash.slice(1)), 0);
853
+ });
854
+
552
855
  window.addEventListener('scroll', update, { passive: true });
553
856
  update();
554
857
  })();
555
858
  `;
556
859
  }
860
+ function msgPagerJS() {
861
+ return `
862
+ (function() {
863
+ // Direct children only: subagent conversations are inlined inside collapsed
864
+ // tool rows and carry their own .msg-user elements, which are not prompts
865
+ // the reader typed and cannot be scrolled to while collapsed.
866
+ var msgs = Array.from(document.querySelectorAll('.detail-date-group > .msg-user'));
867
+ var pager = document.getElementById('msgPager');
868
+ if (!pager || !msgs.length) return;
869
+ var countEl = document.getElementById('msgPagerCount');
870
+ var btns = Array.from(pager.querySelectorAll('.msg-pager-btn'));
871
+ var header = document.getElementById('sessionHeaderBar');
872
+ pager.hidden = false;
873
+
874
+ // Smooth scrolling fires many scroll events on the way to the target, so a
875
+ // second click mid-flight would otherwise read an in-between position and
876
+ // undo the first. Freeze the index until the animation settles.
877
+ var idx = -1;
878
+ var lockUntil = 0;
879
+
880
+ // scrollIntoView({block:'start'}) parks an element at its scroll-margin-top,
881
+ // which clears the sticky header. Read that same value back instead of
882
+ // guessing a header offset, or the message just jumped to reads as "not
883
+ // reached yet" and the counter falls back to "–".
884
+ function positionIndex() {
885
+ var margin = parseFloat(getComputedStyle(msgs[0]).scrollMarginTop) || 0;
886
+ var line = window.scrollY + margin + 8;
887
+ var cur = -1;
888
+ for (var i = 0; i < msgs.length; i++) {
889
+ if (msgs[i].getBoundingClientRect().top + window.scrollY <= line) cur = i;
890
+ }
891
+ return cur;
892
+ }
893
+
894
+ function render() {
895
+ countEl.textContent = (idx < 0 ? '–' : idx + 1) + ' / ' + msgs.length;
896
+ btns[0].disabled = idx <= 0;
897
+ btns[1].disabled = idx >= msgs.length - 1;
898
+ }
899
+
900
+ // A session can hold hundreds of prompts and positionIndex() measures every
901
+ // one, so coalesce the scroll storm into one measurement per frame.
902
+ var queued = false;
903
+ function sync() {
904
+ if (queued) return;
905
+ queued = true;
906
+ requestAnimationFrame(function() {
907
+ queued = false;
908
+ if (Date.now() < lockUntil) return;
909
+ idx = positionIndex();
910
+ render();
911
+ });
912
+ }
913
+
914
+ // Jump instantly rather than smoothly: prompts in a long session sit tens of
915
+ // thousands of pixels apart, where a smooth scroll is a long blur rather than
916
+ // a sense of place. Re-measure the position each time so a jump still works
917
+ // after the reader has scrolled away by hand.
918
+ function go(dir) {
919
+ var cur = Date.now() < lockUntil ? idx : positionIndex();
920
+ var target = Math.max(0, Math.min(msgs.length - 1, cur < 0 ? 0 : cur + dir));
921
+ idx = target;
922
+ lockUntil = Date.now() + 400;
923
+ render();
924
+ var el = msgs[target];
925
+ el.scrollIntoView({ block: 'start' });
926
+ if (window.ccakashicAlign) window.ccakashicAlign(el, 0);
927
+ msgs.forEach(function(m) { m.classList.remove('focused'); });
928
+ el.classList.add('focused');
929
+ clearTimeout(el._flash);
930
+ el._flash = setTimeout(function() { el.classList.remove('focused'); }, 1600);
931
+ }
932
+
933
+ btns.forEach(function(b) {
934
+ b.addEventListener('click', function() { go(parseInt(b.dataset.dir, 10)); });
935
+ });
936
+ document.addEventListener('keydown', function(e) {
937
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
938
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
939
+ if (e.key === 'n') go(1);
940
+ else if (e.key === 'p') go(-1);
941
+ });
942
+ window.addEventListener('scroll', sync, { passive: true });
943
+ sync();
944
+ })();
945
+ `;
946
+ }
947
+ function messageFilterJS() {
948
+ // Only these hide whole messages; 'cost' just strips badges, so it never
949
+ // empties a date group.
950
+ const hideSelectors = JSON.stringify({
951
+ tools: '.msg-tool',
952
+ injected: '.msg-injected',
953
+ thinking: '.msg-thinking',
954
+ shell: '.msg-local-cmd',
955
+ system: '.msg-system',
956
+ });
957
+ return `
958
+ (function() {
959
+ var KEY = 'ccakashic.msgFilters';
960
+ var HIDE = ${hideSelectors};
961
+ var boxes = Array.from(document.querySelectorAll('#detailFilters input[data-filter]'));
962
+ if (!boxes.length) return;
963
+ var groups = Array.from(document.querySelectorAll('.detail-date-group'));
964
+ var navItems = Array.from(document.querySelectorAll('.detail-sidenav-item[data-date]'));
965
+
966
+ // A date group whose every message is filtered out would otherwise leave a
967
+ // bare heading behind; hide it and its side-nav entry too.
968
+ function updateGroups(state) {
969
+ var hidden = Object.keys(HIDE).filter(function(k) { return state[k] === false; });
970
+ var visibleDates = {};
971
+ groups.forEach(function(g) {
972
+ var total = g.querySelectorAll(':scope > .msg').length;
973
+ var hiddenCount = 0;
974
+ hidden.forEach(function(k) {
975
+ hiddenCount += g.querySelectorAll(':scope > ' + HIDE[k]).length;
976
+ });
977
+ var empty = total > 0 && hiddenCount >= total;
978
+ g.style.display = empty ? 'none' : '';
979
+ if (!empty) visibleDates[g.dataset.date] = true;
980
+ });
981
+ navItems.forEach(function(a) {
982
+ a.style.display = visibleDates[a.dataset.date] ? '' : 'none';
983
+ });
984
+ }
985
+
986
+ function apply(persist) {
987
+ var state = {};
988
+ boxes.forEach(function(b) {
989
+ state[b.dataset.filter] = b.checked;
990
+ document.body.classList.toggle('hide-' + b.dataset.filter, !b.checked);
991
+ });
992
+ if (persist) {
993
+ try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) {}
994
+ }
995
+ updateGroups(state);
996
+ }
997
+
998
+ var saved = {};
999
+ try { saved = JSON.parse(localStorage.getItem(KEY)) || {}; } catch (e) {}
1000
+ boxes.forEach(function(b) {
1001
+ if (saved[b.dataset.filter] === false) b.checked = false;
1002
+ b.addEventListener('change', function() { apply(true); });
1003
+ });
1004
+ apply(false);
1005
+
1006
+ document.querySelectorAll('#detailFilters [data-preset]').forEach(function(btn) {
1007
+ btn.addEventListener('click', function() {
1008
+ var showAll = btn.dataset.preset === 'all';
1009
+ boxes.forEach(function(b) { b.checked = showAll; });
1010
+ apply(true);
1011
+ });
1012
+ });
1013
+ })();
1014
+ `;
1015
+ }
package/dist/parser.js CHANGED
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.parseSessionCached = parseSessionCached;
37
37
  exports.parseSession = parseSession;
38
+ exports.classifyUserText = classifyUserText;
38
39
  const fs = __importStar(require("fs"));
39
40
  const path = __importStar(require("path"));
40
41
  const readline = __importStar(require("readline"));
@@ -199,6 +200,39 @@ function parseLocalCommand(text) {
199
200
  }
200
201
  return null;
201
202
  }
203
+ // A `user` line in the JSONL is not necessarily something the user typed:
204
+ // Claude Code also feeds hook output, skill bodies, task notifications and
205
+ // compaction summaries to the model in the user role. Those read like
206
+ // instructions to the assistant, so they get their own presentation instead of
207
+ // sharing the bubble with what was actually typed. Ordered — the first match
208
+ // wins, and the labelled prefixes are anchored so a message that merely quotes
209
+ // one is not misfiled.
210
+ const INJECTED_PREFIXES = [
211
+ [/^Stop hook feedback:/, 'Hook feedback'],
212
+ [/^Base directory for this skill:/, 'Skill'],
213
+ [/^Another Claude session sent a message:/, 'Agent message'],
214
+ [/^\[Request interrupted by user/, 'Interrupted'],
215
+ [/^\[Your previous response had no visible output/, 'Continuation'],
216
+ [/^Continue from where you left off\./, 'Continuation'],
217
+ ];
218
+ // Returns a label when the text was injected on the user's behalf, or null
219
+ // when it is genuinely typed. Legacy sessions carry no promptSource at all, so
220
+ // "no flags" has to mean typed — never hide a real prompt.
221
+ function classifyUserText(text, line) {
222
+ if (line.isCompactSummary)
223
+ return 'Compact summary';
224
+ if (text.includes('<task-notification>'))
225
+ return 'Task notification';
226
+ for (const [re, label] of INJECTED_PREFIXES) {
227
+ if (re.test(text))
228
+ return label;
229
+ }
230
+ if (line.isMeta)
231
+ return 'Injected';
232
+ if (line.promptSource === 'sdk' || line.promptSource === 'system')
233
+ return 'Injected';
234
+ return null;
235
+ }
202
236
  function processUserText(text, line, messages) {
203
237
  if (text.match(/^<local-command-caveat>/)) {
204
238
  return;
@@ -240,11 +274,16 @@ function processUserText(text, line, messages) {
240
274
  .replace(/<local-command-stdout>[\s\S]*?<\/local-command-stdout>/g, '')
241
275
  .trim();
242
276
  if (stripped) {
277
+ // Stays type 'user' so it still opens a turn for turn-usage accounting;
278
+ // only the presentation differs.
279
+ const injectedKind = classifyUserText(stripped, line);
243
280
  messages.push({
244
281
  type: 'user',
245
282
  text: stripped,
246
283
  timestamp: line.timestamp,
247
284
  uuid: line.uuid,
285
+ injected: injectedKind !== null,
286
+ injectedKind,
248
287
  });
249
288
  }
250
289
  }
@@ -663,8 +663,11 @@ function getAppJS() {
663
663
  });
664
664
  }
665
665
 
666
- // Keyboard navigation: j/k to move between user messages
667
- var msgEls = Array.from(document.querySelectorAll('.msg-user, .msg-assistant'));
666
+ // Keyboard navigation: j/k to move between user messages. Subagent
667
+ // conversations are inlined inside collapsed tool rows, so their messages
668
+ // can't be scrolled to — skip them rather than swallowing keypresses.
669
+ var msgEls = Array.from(document.querySelectorAll('.msg-user, .msg-assistant'))
670
+ .filter(function(el) { return !el.closest('.subagent-content'); });
668
671
  var currentIdx = -1;
669
672
 
670
673
  document.addEventListener('keydown', function(e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccakashic",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "A cross-project dashboard for your Claude Code sessions (~/.claude/projects/) — browse logs as beautiful HTML, see which sessions are waiting for you, and resume any of them in one click via cmux",
5
5
  "bin": {
6
6
  "ccakashic": "dist/bin/ccakashic.js"