claude-code-kanban 7.14.0 → 7.15.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/lib/parsers.js +28 -3
- package/package.json +1 -1
- package/public/app.js +95 -23
- package/public/index.html +8 -2
- package/public/style.css +52 -0
package/lib/parsers.js
CHANGED
|
@@ -673,16 +673,41 @@ function countTranscriptsBornBefore(dir, birthtimeMs) {
|
|
|
673
673
|
// Cache: jsonlPath -> { ino, size, mtimeMs }, the transcript as it last grew.
|
|
674
674
|
// Claude Code, `claude --resume` and backup tools set a transcript's mtime without writing
|
|
675
675
|
// to it (#50), so only a file that grew, shrank or was replaced counts as activity.
|
|
676
|
+
// When a session exits, Claude Code appends bookkeeping lines (cost-state, last-prompt,
|
|
677
|
+
// mode, ai-title ...) with no `timestamp`; a write of only such lines is not activity either.
|
|
676
678
|
const logActivityCache = new Map();
|
|
679
|
+
const BOOKKEEPING_APPEND_MAX = 64 * 1024;
|
|
680
|
+
|
|
681
|
+
function isBookkeepingAppend(jsonlPath, from, to) {
|
|
682
|
+
if (to - from > BOOKKEEPING_APPEND_MAX || from < 1) return false;
|
|
683
|
+
// Reads from one byte early, so the append is known to start on a line boundary.
|
|
684
|
+
const buf = Buffer.allocUnsafe(to - from + 1);
|
|
685
|
+
try {
|
|
686
|
+
const fd = fs.openSync(jsonlPath, 'r');
|
|
687
|
+
try { fs.readSync(fd, buf, 0, buf.length, from - 1); } finally { fs.closeSync(fd); }
|
|
688
|
+
} catch { return false; }
|
|
689
|
+
const text = buf.toString('utf8');
|
|
690
|
+
if (text[0] !== '\n' || !text.endsWith('\n') || text.includes('"timestamp":')) return false;
|
|
691
|
+
return text.slice(1, -1).split('\n').every((line) => {
|
|
692
|
+
if (!line) return true;
|
|
693
|
+
try {
|
|
694
|
+
const obj = JSON.parse(line);
|
|
695
|
+
return obj !== null && typeof obj === 'object';
|
|
696
|
+
} catch { return false; }
|
|
697
|
+
});
|
|
698
|
+
}
|
|
677
699
|
|
|
678
700
|
function transcriptActivityMs(jsonlPath, stat) {
|
|
679
701
|
const prev = logActivityCache.get(jsonlPath);
|
|
680
|
-
|
|
702
|
+
const sameFile = prev && prev.ino === stat.ino;
|
|
703
|
+
if (sameFile && prev.size === stat.size) return prev.mtimeMs;
|
|
704
|
+
const quiet = sameFile && stat.size > prev.size && isBookkeepingAppend(jsonlPath, prev.size, stat.size);
|
|
705
|
+
const mtimeMs = quiet ? prev.mtimeMs : stat.mtimeMs;
|
|
681
706
|
logActivityCache.delete(jsonlPath);
|
|
682
|
-
logActivityCache.set(jsonlPath, { ino: stat.ino, size: stat.size, mtimeMs
|
|
707
|
+
logActivityCache.set(jsonlPath, { ino: stat.ino, size: stat.size, mtimeMs });
|
|
683
708
|
if (logActivityCache.size > SESSION_INFO_CACHE_MAX) logActivityCache.delete(logActivityCache.keys().next().value);
|
|
684
709
|
sessionCacheDirty = true;
|
|
685
|
-
return
|
|
710
|
+
return mtimeMs;
|
|
686
711
|
}
|
|
687
712
|
|
|
688
713
|
const sessionCachesDirty = () => sessionCacheDirty;
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -369,7 +369,7 @@ async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
|
|
|
369
369
|
schedulePublishSession();
|
|
370
370
|
}
|
|
371
371
|
// A list too short to scroll gives no scroll event, so fill it until it can scroll.
|
|
372
|
-
if (sessionsHasMore) setTimeout(loadMoreIfNearEnd);
|
|
372
|
+
if (sessionsHasMore) setTimeout(() => loadMoreIfNearEnd());
|
|
373
373
|
|
|
374
374
|
if (includeTasks && searchQuery) await loadSearchTasks();
|
|
375
375
|
} catch (error) {
|
|
@@ -418,11 +418,11 @@ function resetSessionPage() {
|
|
|
418
418
|
}
|
|
419
419
|
|
|
420
420
|
const LOAD_MORE_MARGIN_PX = 240;
|
|
421
|
-
function loadMoreIfNearEnd() {
|
|
422
|
-
const { scrollTop, clientHeight, scrollHeight } =
|
|
423
|
-
if (clientHeight > 0 && scrollTop + clientHeight >= scrollHeight - LOAD_MORE_MARGIN_PX)
|
|
421
|
+
function loadMoreIfNearEnd(el = sessionsList, load = loadMoreSessions) {
|
|
422
|
+
const { scrollTop, clientHeight, scrollHeight } = el;
|
|
423
|
+
if (clientHeight > 0 && scrollTop + clientHeight >= scrollHeight - LOAD_MORE_MARGIN_PX) load();
|
|
424
424
|
}
|
|
425
|
-
sessionsList.addEventListener('scroll', loadMoreIfNearEnd, { passive: true });
|
|
425
|
+
sessionsList.addEventListener('scroll', () => loadMoreIfNearEnd(), { passive: true });
|
|
426
426
|
|
|
427
427
|
// Sessions by id, in the order given, ignoring every sidebar filter — that is what `include` means.
|
|
428
428
|
async function fetchSessionsByIds(ids) {
|
|
@@ -6446,6 +6446,7 @@ const SHORTCUT_TABS = [
|
|
|
6446
6446
|
{ keys: ['I'], label: 'Session info' },
|
|
6447
6447
|
{ keys: ['.'], label: 'Pin / unpin' },
|
|
6448
6448
|
{ keys: ['>'], label: 'Toggle sticky' },
|
|
6449
|
+
{ keys: ['Ctrl', 'Shift', '.'], combo: true, label: 'Toggle sticky (from the terminal)' },
|
|
6449
6450
|
{ keys: ['Shift', 'F10'], combo: true, label: 'Session menu: move to group, fork (or Menu key)' },
|
|
6450
6451
|
{ keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session (pinned: to Pinned group)' },
|
|
6451
6452
|
{ keys: ['Ctrl', 'Shift', 'L'], combo: true, label: 'Toggle session log' },
|
|
@@ -12273,6 +12274,52 @@ let spFromTerminal = false;
|
|
|
12273
12274
|
let spRenderedQuery = null;
|
|
12274
12275
|
// Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
|
|
12275
12276
|
const spPeekCache = new Map();
|
|
12277
|
+
// 'board' offers the sidebar's sessions plus a short tail of the newest others; 'all' offers
|
|
12278
|
+
// every session, newest first, paged like the sidebar's All filter. Each open starts on 'board'.
|
|
12279
|
+
let spScope = 'board';
|
|
12280
|
+
let spRecent = { rows: [], limit: 0, hasMore: true, loading: false };
|
|
12281
|
+
let spRowEls = [];
|
|
12282
|
+
const SP_RECENT_PAGE = 50;
|
|
12283
|
+
const SP_RECENT_TAIL = 5;
|
|
12284
|
+
|
|
12285
|
+
// The first load asks for the board's count plus the tail, so the tail is there even when
|
|
12286
|
+
// the newest sessions are all on the board.
|
|
12287
|
+
async function spLoadRecent() {
|
|
12288
|
+
const state = spRecent;
|
|
12289
|
+
if (state.loading || !state.hasMore) return;
|
|
12290
|
+
state.loading = true;
|
|
12291
|
+
state.limit = state.limit ? state.limit + SP_RECENT_PAGE : spSource.length + SP_RECENT_TAIL;
|
|
12292
|
+
try {
|
|
12293
|
+
const res = await api('/api/sessions', { query: { limit: state.limit, pins: 'off' } });
|
|
12294
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
12295
|
+
const rows = await res.json();
|
|
12296
|
+
if (state !== spRecent) return;
|
|
12297
|
+
state.rows = rows;
|
|
12298
|
+
state.hasMore = res.headers.get('X-Has-More') === 'true';
|
|
12299
|
+
} catch (error) {
|
|
12300
|
+
console.error('Failed to load recent sessions:', error);
|
|
12301
|
+
state.hasMore = false;
|
|
12302
|
+
} finally {
|
|
12303
|
+
state.loading = false;
|
|
12304
|
+
}
|
|
12305
|
+
if (!isSessionPickerOpen()) return;
|
|
12306
|
+
renderSessionPicker();
|
|
12307
|
+
spLoadMoreNearEnd();
|
|
12308
|
+
}
|
|
12309
|
+
|
|
12310
|
+
function spSetScope(scope) {
|
|
12311
|
+
spScope = scope;
|
|
12312
|
+
for (const el of document.querySelectorAll('#session-picker-scope [data-scope]')) {
|
|
12313
|
+
el.setAttribute('aria-checked', String(el.dataset.scope === scope));
|
|
12314
|
+
}
|
|
12315
|
+
renderSessionPicker();
|
|
12316
|
+
spLoadMoreNearEnd();
|
|
12317
|
+
}
|
|
12318
|
+
|
|
12319
|
+
// Also run after each load: a page too short to scroll gives no scroll event.
|
|
12320
|
+
function spLoadMoreNearEnd() {
|
|
12321
|
+
if (spScope === 'all') loadMoreIfNearEnd(document.getElementById('session-picker-list'), spLoadRecent);
|
|
12322
|
+
}
|
|
12276
12323
|
|
|
12277
12324
|
const spSearch = sessionSearch(() => {
|
|
12278
12325
|
if (document.getElementById('session-picker-modal').classList.contains('visible')) renderSessionPicker();
|
|
@@ -12285,9 +12332,11 @@ function openSessionPicker() {
|
|
|
12285
12332
|
spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
|
|
12286
12333
|
spPeekCache.clear();
|
|
12287
12334
|
spRenderedQuery = null;
|
|
12335
|
+
spRecent = { rows: [], limit: 0, hasMore: true, loading: false };
|
|
12288
12336
|
document.getElementById('session-picker-modal').classList.add('visible');
|
|
12289
|
-
|
|
12337
|
+
spSetScope('board');
|
|
12290
12338
|
input.focus();
|
|
12339
|
+
spLoadRecent();
|
|
12291
12340
|
}
|
|
12292
12341
|
|
|
12293
12342
|
// refocus: false when a session is being opened, which moves the terminal focus itself.
|
|
@@ -12403,29 +12452,39 @@ function spPinHtml(session) {
|
|
|
12403
12452
|
function renderSessionPicker() {
|
|
12404
12453
|
const list = document.getElementById('session-picker-list');
|
|
12405
12454
|
const query = document.getElementById('session-picker-input').value.trim();
|
|
12406
|
-
//
|
|
12407
|
-
const
|
|
12408
|
-
|
|
12409
|
-
|
|
12455
|
+
// Rows that arrive for the same query and scope append below, so the cursor stays on its row.
|
|
12456
|
+
const renderKey = `${spScope}\n${query}`;
|
|
12457
|
+
const selectedId = renderKey === spRenderedQuery ? spRows[spIdx]?.id : null;
|
|
12458
|
+
spRenderedQuery = renderKey;
|
|
12459
|
+
const all = spScope === 'all';
|
|
12460
|
+
const board = new Set(spSource.map((s) => s.id));
|
|
12461
|
+
const local = (all ? spRecent.rows : spSource).filter((s) => spMatches(s, query));
|
|
12410
12462
|
const found = spSearch.rows(query, local);
|
|
12411
|
-
|
|
12463
|
+
const tail = !all && !query ? spRecent.rows.filter((s) => !board.has(s.id)).slice(0, SP_RECENT_TAIL) : [];
|
|
12464
|
+
const extra = [...found.rows, ...tail];
|
|
12465
|
+
spRows = [...local, ...extra];
|
|
12412
12466
|
if (!spRows.length) {
|
|
12413
12467
|
spIdx = -1;
|
|
12414
|
-
|
|
12415
|
-
|
|
12416
|
-
|
|
12417
|
-
? '
|
|
12418
|
-
:
|
|
12468
|
+
spRowEls = [];
|
|
12469
|
+
const empty =
|
|
12470
|
+
found.pending || (all && spRecent.loading)
|
|
12471
|
+
? 'Searching all sessions…'
|
|
12472
|
+
: all || spSource.length || query.length >= SESSION_SEARCH_MIN
|
|
12473
|
+
? 'No session matches'
|
|
12474
|
+
: 'No sessions in the current sidebar filter';
|
|
12419
12475
|
list.innerHTML = `<div class="sp-empty">${empty}</div>`;
|
|
12420
12476
|
return;
|
|
12421
12477
|
}
|
|
12422
12478
|
|
|
12423
|
-
// Rows
|
|
12479
|
+
// Rows not on the board show their full project path; in the board scope they also sit below
|
|
12480
|
+
// the board's rows, under a label.
|
|
12481
|
+
const section = !all && extra.length ? '<div class="sp-section">Not on board</div>' : '';
|
|
12424
12482
|
list.innerHTML = spRows
|
|
12425
12483
|
.map((s, i) => {
|
|
12426
|
-
const outside =
|
|
12484
|
+
const outside = !board.has(s.id);
|
|
12485
|
+
const below = !all && i >= local.length;
|
|
12427
12486
|
const project = s.project ? (outside ? s.project : pathBasename(s.project)) : '';
|
|
12428
|
-
return
|
|
12487
|
+
return `${below && i === local.length ? section : ''}<button class="sp-row${s.id === currentSessionId ? ' current' : ''}${below ? ' outside' : ''} ${sessionBarClass(s)}" data-idx="${i}" title="${escapeHtml(s.project ? `${s.id}\n${s.project}` : s.id)}">
|
|
12429
12488
|
${spDotHtml(s)}
|
|
12430
12489
|
<span class="sp-name">${escapeHtml(sessionDisplayName(s))}</span>
|
|
12431
12490
|
<span class="sp-project">${escapeHtml(project)}</span>
|
|
@@ -12436,18 +12495,21 @@ function renderSessionPicker() {
|
|
|
12436
12495
|
</button>`;
|
|
12437
12496
|
})
|
|
12438
12497
|
.join('');
|
|
12498
|
+
// The section label sits between rows, so row index and child index differ.
|
|
12499
|
+
spRowEls = list.querySelectorAll('.sp-row');
|
|
12500
|
+
spIdx = -1;
|
|
12439
12501
|
const keep = spRows.findIndex((s) => s.id === selectedId);
|
|
12440
12502
|
spSelect(Math.max(0, keep));
|
|
12441
12503
|
}
|
|
12442
12504
|
|
|
12443
12505
|
function spSelect(idx) {
|
|
12444
|
-
|
|
12445
|
-
list.children[spIdx]?.classList.remove('selected');
|
|
12506
|
+
spRowEls[spIdx]?.classList.remove('selected');
|
|
12446
12507
|
spIdx = Math.min(Math.max(idx, 0), spRows.length - 1);
|
|
12447
|
-
const row =
|
|
12508
|
+
const row = spRowEls[spIdx];
|
|
12448
12509
|
row?.classList.add('selected');
|
|
12449
12510
|
row?.scrollIntoView({ block: 'nearest' });
|
|
12450
12511
|
spSchedulePeek();
|
|
12512
|
+
if (spScope === 'all' && spIdx >= spRows.length - 5) spLoadRecent();
|
|
12451
12513
|
}
|
|
12452
12514
|
|
|
12453
12515
|
async function spOpen(idx) {
|
|
@@ -12468,14 +12530,22 @@ function initSessionPicker() {
|
|
|
12468
12530
|
else if (matchKey(e, 'ArrowUp') || (e.ctrlKey && !e.shiftKey && e.key === 'p')) spSelect(spIdx - 1);
|
|
12469
12531
|
else if (e.key === 'Enter') spOpen(spIdx);
|
|
12470
12532
|
else if (e.ctrlKey && e.code === 'Space') spSetPeeking(!spIsPeeking());
|
|
12533
|
+
else if (e.key === 'Tab' && !e.ctrlKey && !e.altKey && !e.metaKey) spSetScope(spScope === 'all' ? 'board' : 'all');
|
|
12471
12534
|
else return;
|
|
12472
12535
|
e.preventDefault();
|
|
12473
12536
|
e.stopPropagation();
|
|
12474
12537
|
});
|
|
12475
|
-
document.getElementById('session-picker-list')
|
|
12538
|
+
const list = document.getElementById('session-picker-list');
|
|
12539
|
+
list.addEventListener('click', (e) => {
|
|
12476
12540
|
const row = e.target.closest('.sp-row');
|
|
12477
12541
|
if (row) spOpen(Number(row.dataset.idx));
|
|
12478
12542
|
});
|
|
12543
|
+
list.addEventListener('scroll', spLoadMoreNearEnd, { passive: true });
|
|
12544
|
+
document.getElementById('session-picker-scope').addEventListener('click', (e) => {
|
|
12545
|
+
const scope = e.target.closest('[data-scope]')?.dataset.scope;
|
|
12546
|
+
if (scope) spSetScope(scope);
|
|
12547
|
+
input.focus();
|
|
12548
|
+
});
|
|
12479
12549
|
// Vimium eats Escape inside a text input and only blurs it, so the page never sees the key.
|
|
12480
12550
|
// A blur that no click in the picker caused, while the window keeps focus, is that Escape.
|
|
12481
12551
|
const modal = document.getElementById('session-picker-modal');
|
|
@@ -15045,6 +15115,7 @@ function terminalShortcut(e, probe = false) {
|
|
|
15045
15115
|
if (ctrlShift && e.code === 'KeyL') return toggleMessagePanel;
|
|
15046
15116
|
if (ctrlShift && e.code === 'KeyM') return toggleLastMessage;
|
|
15047
15117
|
if (ctrlShift && e.code === 'KeyD') return dismissContextSession;
|
|
15118
|
+
if (ctrlShift && e.code === 'Period') return currentSessionId ? () => toggleSessionSticky(currentSessionId) : null;
|
|
15048
15119
|
if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
|
|
15049
15120
|
return () => openNewSession(null, e.code === 'KeyR');
|
|
15050
15121
|
}
|
|
@@ -15365,6 +15436,7 @@ const TERMINAL_PROBE_KEYS = [
|
|
|
15365
15436
|
['Backquote', '`', '~'],
|
|
15366
15437
|
['Equal', '=', '+'],
|
|
15367
15438
|
['Minus', '-', '_'],
|
|
15439
|
+
['Period', '.', '>'],
|
|
15368
15440
|
['NumpadAdd', '+', '+'],
|
|
15369
15441
|
['NumpadSubtract', '-', '-'],
|
|
15370
15442
|
['Numpad0', '0', '0'],
|
package/public/index.html
CHANGED
|
@@ -885,12 +885,18 @@
|
|
|
885
885
|
|
|
886
886
|
<div id="session-picker-modal" class="modal-overlay top-aligned" onclick="closeSessionPicker()">
|
|
887
887
|
<div class="modal session-picker" onclick="event.stopPropagation()">
|
|
888
|
-
<
|
|
888
|
+
<div class="sp-head">
|
|
889
|
+
<input type="text" id="session-picker-input" class="form-input sp-input" placeholder="Jump to session..." autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false">
|
|
890
|
+
<div id="session-picker-scope" class="sp-scope" role="radiogroup" aria-label="Sessions" title="Tab switches scope">
|
|
891
|
+
<button type="button" role="radio" tabindex="-1" data-scope="board">Board</button>
|
|
892
|
+
<button type="button" role="radio" tabindex="-1" data-scope="all">All</button>
|
|
893
|
+
</div>
|
|
894
|
+
</div>
|
|
889
895
|
<div class="sp-body">
|
|
890
896
|
<div id="session-picker-list" class="sp-list"></div>
|
|
891
897
|
<div id="session-picker-peek" class="sp-peek"></div>
|
|
892
898
|
</div>
|
|
893
|
-
<div class="sp-hint"><kbd>↑</kbd><kbd>↓</kbd> move · <kbd>Enter</kbd> open · <kbd>Ctrl</kbd><kbd>Space</kbd> peek · <kbd>Esc</kbd> close</div>
|
|
899
|
+
<div class="sp-hint"><kbd>↑</kbd><kbd>↓</kbd> move · <kbd>Enter</kbd> open · <kbd>Tab</kbd> board / all · <kbd>Ctrl</kbd><kbd>Space</kbd> peek · <kbd>Esc</kbd> close</div>
|
|
894
900
|
</div>
|
|
895
901
|
</div>
|
|
896
902
|
|
package/public/style.css
CHANGED
|
@@ -8746,6 +8746,54 @@ select.form-input option:checked {
|
|
|
8746
8746
|
overflow: hidden;
|
|
8747
8747
|
}
|
|
8748
8748
|
|
|
8749
|
+
.sp-head {
|
|
8750
|
+
display: flex;
|
|
8751
|
+
align-items: center;
|
|
8752
|
+
gap: 8px;
|
|
8753
|
+
}
|
|
8754
|
+
|
|
8755
|
+
.sp-head .sp-input {
|
|
8756
|
+
flex: 1;
|
|
8757
|
+
}
|
|
8758
|
+
|
|
8759
|
+
.sp-scope {
|
|
8760
|
+
flex: none;
|
|
8761
|
+
display: flex;
|
|
8762
|
+
gap: 2px;
|
|
8763
|
+
padding: 2px;
|
|
8764
|
+
border: 1px solid var(--border);
|
|
8765
|
+
border-radius: 6px;
|
|
8766
|
+
}
|
|
8767
|
+
|
|
8768
|
+
.sp-scope button {
|
|
8769
|
+
padding: 4px 10px;
|
|
8770
|
+
background: none;
|
|
8771
|
+
border: none;
|
|
8772
|
+
border-radius: 4px;
|
|
8773
|
+
font-family: var(--mono);
|
|
8774
|
+
font-size: 11.5px;
|
|
8775
|
+
color: var(--text-muted);
|
|
8776
|
+
cursor: pointer;
|
|
8777
|
+
}
|
|
8778
|
+
|
|
8779
|
+
.sp-scope button:hover {
|
|
8780
|
+
color: var(--text-secondary);
|
|
8781
|
+
}
|
|
8782
|
+
|
|
8783
|
+
.sp-scope button[aria-checked='true'] {
|
|
8784
|
+
background: var(--bg-deep);
|
|
8785
|
+
color: var(--text-primary);
|
|
8786
|
+
}
|
|
8787
|
+
|
|
8788
|
+
.sp-section {
|
|
8789
|
+
margin-top: 8px;
|
|
8790
|
+
padding: 2px 8px;
|
|
8791
|
+
color: var(--text-muted);
|
|
8792
|
+
font-size: 10.5px;
|
|
8793
|
+
letter-spacing: 0.04em;
|
|
8794
|
+
text-transform: uppercase;
|
|
8795
|
+
}
|
|
8796
|
+
|
|
8749
8797
|
.sp-input {
|
|
8750
8798
|
padding: 10px 12px;
|
|
8751
8799
|
background: var(--bg-deep);
|
|
@@ -8909,6 +8957,10 @@ select.form-input option:checked {
|
|
|
8909
8957
|
margin-top: 8px;
|
|
8910
8958
|
}
|
|
8911
8959
|
|
|
8960
|
+
.sp-section + .sp-row.outside {
|
|
8961
|
+
margin-top: 0;
|
|
8962
|
+
}
|
|
8963
|
+
|
|
8912
8964
|
.sp-row.current .sp-name {
|
|
8913
8965
|
color: var(--accent-text);
|
|
8914
8966
|
}
|