ccakashic 0.3.1 → 0.5.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 +6 -1
- package/dist/api.js +48 -0
- package/dist/bin/ccakashic.js +31 -3
- package/dist/cmux.js +122 -0
- package/dist/dashboard.js +1 -0
- package/dist/discover.js +27 -0
- package/dist/html-generator.js +495 -36
- package/dist/parser.js +39 -0
- package/dist/template-assets.js +5 -2
- package/package.json +1 -1
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,8 +51,9 @@ 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
|
|
56
|
+
- **Read-only JSON feed** — `GET /api/sessions?limit=40&waiting=1` returns what the dashboard shows (title, project, branch, model, `status`, `waiting`, `detailUrl`, `resumeCommand`) so other local tools can reuse the waiting signal. `waiting=1` returns only the sessions asking for you, and is the cheap path — it looks those up by id instead of parsing a whole window of session files
|
|
52
57
|
- **Zero dependencies** — Node.js built-in modules only
|
|
53
58
|
|
|
54
59
|
## cmux integration
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_SESSION_LIMIT = exports.DEFAULT_SESSION_LIMIT = void 0;
|
|
4
|
+
exports.toSessionRow = toSessionRow;
|
|
5
|
+
exports.parseSessionLimit = parseSessionLimit;
|
|
6
|
+
exports.orderSessionRows = orderSessionRows;
|
|
7
|
+
const dashboard_1 = require("./dashboard");
|
|
8
|
+
const cmux_1 = require("./cmux");
|
|
9
|
+
// Read-only JSON view of what the dashboard already computes, so other local
|
|
10
|
+
// tools can treat ccakashic as the source of truth for "which session is
|
|
11
|
+
// waiting for me" instead of re-deriving it from ~/.claude. The waiting signal
|
|
12
|
+
// in particular cannot be rebuilt elsewhere: it maps cmux's unread
|
|
13
|
+
// notifications through ccakashic's own resume map.
|
|
14
|
+
exports.DEFAULT_SESSION_LIMIT = 40;
|
|
15
|
+
// Each row costs a full session-file read, so cap what one request can ask for.
|
|
16
|
+
exports.MAX_SESSION_LIMIT = 100;
|
|
17
|
+
const PREVIEW_MAX = 200;
|
|
18
|
+
function toSessionRow(session, waiting) {
|
|
19
|
+
return {
|
|
20
|
+
id: session.id,
|
|
21
|
+
projectRawName: session.projectRawName,
|
|
22
|
+
projectName: session.projectName,
|
|
23
|
+
cwd: session.cwd,
|
|
24
|
+
title: (0, dashboard_1.paneTitle)(session),
|
|
25
|
+
preview: (session.preview || '').slice(0, PREVIEW_MAX),
|
|
26
|
+
gitBranch: session.gitBranch,
|
|
27
|
+
model: session.model,
|
|
28
|
+
lastModified: session.lastModified,
|
|
29
|
+
status: (0, dashboard_1.paneStatus)(session.lastModified),
|
|
30
|
+
waiting,
|
|
31
|
+
detailUrl: `/project/${encodeURIComponent(session.projectRawName)}/session/${encodeURIComponent(session.id)}`,
|
|
32
|
+
// /api/resume is POST-only and requires the CSRF token, so there is no GET
|
|
33
|
+
// URL to hand out — and publishing that token from an unauthenticated
|
|
34
|
+
// endpoint would defeat it. Callers that want to act on a session either
|
|
35
|
+
// open detailUrl and press Resume, or run resumeCommand themselves.
|
|
36
|
+
resumeUrl: null,
|
|
37
|
+
resumeCommand: session.cwd ? (0, cmux_1.buildResumeCommand)(session.cwd, session.id) : null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function parseSessionLimit(raw) {
|
|
41
|
+
const n = parseInt(raw || '', 10);
|
|
42
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
43
|
+
return exports.DEFAULT_SESSION_LIMIT;
|
|
44
|
+
return Math.min(n, exports.MAX_SESSION_LIMIT);
|
|
45
|
+
}
|
|
46
|
+
function orderSessionRows(rows, limit) {
|
|
47
|
+
return rows.slice().sort((a, b) => b.lastModified - a.lastModified).slice(0, limit);
|
|
48
|
+
}
|
package/dist/bin/ccakashic.js
CHANGED
|
@@ -41,6 +41,7 @@ const path = __importStar(require("path"));
|
|
|
41
41
|
const child_process_1 = require("child_process");
|
|
42
42
|
const util_1 = require("../util");
|
|
43
43
|
const discover_1 = require("../discover");
|
|
44
|
+
const api_1 = require("../api");
|
|
44
45
|
const parser_1 = require("../parser");
|
|
45
46
|
const html_generator_1 = require("../html-generator");
|
|
46
47
|
const pages_1 = require("../pages");
|
|
@@ -100,16 +101,25 @@ async function buildResumeContext() {
|
|
|
100
101
|
}
|
|
101
102
|
return { token: RESUME_TOKEN, cmuxAvailable, openSessionIds };
|
|
102
103
|
}
|
|
103
|
-
// sessionId → wait reason, from cmux's unread notifications
|
|
104
|
-
//
|
|
105
|
-
//
|
|
104
|
+
// sessionId → wait reason, from cmux's unread notifications resolved through
|
|
105
|
+
// two independent workspace→session sources. Empty when cmux is
|
|
106
|
+
// unavailable/disabled.
|
|
106
107
|
async function buildCmuxWaitMap() {
|
|
107
108
|
const result = new Map();
|
|
108
109
|
if (NO_CMUX || !(await (0, cmux_1.isCmuxAvailable)()))
|
|
109
110
|
return result;
|
|
110
111
|
try {
|
|
111
112
|
const waiting = await (0, cmux_1.listWaitingWorkspacesCached)();
|
|
113
|
+
// The resume map only covers sessions ccakashic resumed, which left every
|
|
114
|
+
// hand-started session permanently unbadged. The live map reads
|
|
115
|
+
// CMUX_WORKSPACE_ID from each running session's own process and covers
|
|
116
|
+
// those. They complement each other — the resume map still resolves
|
|
117
|
+
// sessions that have since exited — so the live one is layered on top,
|
|
118
|
+
// winning conflicts because it reflects the process attached right now.
|
|
112
119
|
const wsToSession = (0, cmux_1.loadWorkspaceToSession)();
|
|
120
|
+
for (const [wsId, sessionId] of await (0, cmux_1.liveWorkspaceToSessionCached)()) {
|
|
121
|
+
wsToSession.set(wsId, sessionId);
|
|
122
|
+
}
|
|
113
123
|
for (const [wsId, reason] of waiting) {
|
|
114
124
|
const sessionId = wsToSession.get(wsId);
|
|
115
125
|
if (sessionId)
|
|
@@ -291,6 +301,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
291
301
|
res.end(JSON.stringify({ changed: true, mtime, status, ago, waiting, html: (0, dashboard_1.renderPaneBody)(parsed) }));
|
|
292
302
|
return;
|
|
293
303
|
}
|
|
304
|
+
// Read-only feed of the dashboard's own view, for other local tools.
|
|
305
|
+
if (pathname === '/api/sessions') {
|
|
306
|
+
const limit = (0, api_1.parseSessionLimit)(url.searchParams.get('limit'));
|
|
307
|
+
const waitingOnly = url.searchParams.get('waiting') === '1';
|
|
308
|
+
const cmuxWait = await buildCmuxWaitMap();
|
|
309
|
+
// Waiting sessions are fetched by id rather than filtered out of the
|
|
310
|
+
// recent window: a session can sit waiting while other projects churn
|
|
311
|
+
// past it, and reading a wide window means parsing every file in it.
|
|
312
|
+
const sessions = waitingOnly
|
|
313
|
+
? await (0, discover_1.findRecentSessionsByIds)([...cmuxWait.keys()])
|
|
314
|
+
: await (0, discover_1.listRecentSessions)(limit);
|
|
315
|
+
const rows = (0, api_1.orderSessionRows)(sessions
|
|
316
|
+
.map((s) => (0, api_1.toSessionRow)(s, resolveWaiting(s.id, cmuxWait)))
|
|
317
|
+
.filter((r) => !waitingOnly || r.waiting !== null), limit);
|
|
318
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
319
|
+
res.end(JSON.stringify(rows));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
294
322
|
const projectMatch = pathname.match(/^\/project\/(.+)$/);
|
|
295
323
|
if (projectMatch && !pathname.includes('/session/')) {
|
|
296
324
|
const rawName = decodeURIComponent(projectMatch[1]);
|
package/dist/cmux.js
CHANGED
|
@@ -49,6 +49,10 @@ exports.openInCmuxBrowser = openInCmuxBrowser;
|
|
|
49
49
|
exports.loadResumeMap = loadResumeMap;
|
|
50
50
|
exports.saveResumeMapEntry = saveResumeMapEntry;
|
|
51
51
|
exports.loadWorkspaceToSession = loadWorkspaceToSession;
|
|
52
|
+
exports.loadSessionRegistry = loadSessionRegistry;
|
|
53
|
+
exports.parseWorkspaceEnv = parseWorkspaceEnv;
|
|
54
|
+
exports.liveWorkspaceToSession = liveWorkspaceToSession;
|
|
55
|
+
exports.liveWorkspaceToSessionCached = liveWorkspaceToSessionCached;
|
|
52
56
|
exports.findLiveWorkspaceForSession = findLiveWorkspaceForSession;
|
|
53
57
|
const child_process_1 = require("child_process");
|
|
54
58
|
const fs = __importStar(require("fs"));
|
|
@@ -260,6 +264,124 @@ function loadWorkspaceToSession() {
|
|
|
260
264
|
}
|
|
261
265
|
return inv;
|
|
262
266
|
}
|
|
267
|
+
// --- Live workspace mapping, read from the running processes themselves ---
|
|
268
|
+
//
|
|
269
|
+
// The resume map only knows about sessions ccakashic itself resumed, so a
|
|
270
|
+
// session you started by hand inside cmux has no workspace mapping and never
|
|
271
|
+
// gets a waiting badge. Claude Code registers every running session in
|
|
272
|
+
// ~/.claude/sessions/<pid>.json, and a session launched inside cmux inherits
|
|
273
|
+
// CMUX_WORKSPACE_ID in its environment — together those give the same mapping
|
|
274
|
+
// without ccakashic having been involved.
|
|
275
|
+
//
|
|
276
|
+
// The two sources are complements, not replacements: this one sees only live
|
|
277
|
+
// processes, while the resume map still covers sessions that have since exited.
|
|
278
|
+
const SESSION_REGISTRY_DIR = path.join(os.homedir(), '.claude', 'sessions');
|
|
279
|
+
function loadSessionRegistry() {
|
|
280
|
+
let names;
|
|
281
|
+
try {
|
|
282
|
+
names = fs.readdirSync(SESSION_REGISTRY_DIR);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return []; // no registry (older Claude Code, or nothing has run yet)
|
|
286
|
+
}
|
|
287
|
+
const out = [];
|
|
288
|
+
for (const f of names) {
|
|
289
|
+
if (!f.endsWith('.json'))
|
|
290
|
+
continue;
|
|
291
|
+
try {
|
|
292
|
+
const o = JSON.parse(fs.readFileSync(path.join(SESSION_REGISTRY_DIR, f), 'utf-8'));
|
|
293
|
+
if (typeof o?.pid === 'number' && typeof o?.sessionId === 'string') {
|
|
294
|
+
out.push({ pid: o.pid, sessionId: o.sessionId, procStart: o.procStart });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// a half-written or stale record; skip it
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
function isProcessAlive(pid) {
|
|
304
|
+
try {
|
|
305
|
+
process.kill(pid, 0); // signal 0 only probes; it does not signal
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// `ps eww` prints one line per process: pid, then the command, then the
|
|
313
|
+
// process's ENTIRE environment — which routinely holds API keys and tokens.
|
|
314
|
+
// Only CMUX_WORKSPACE_ID is ever pulled out of it; no other variable is
|
|
315
|
+
// stored, returned or logged, and the raw output is not retained.
|
|
316
|
+
function parseWorkspaceEnv(psOutput) {
|
|
317
|
+
const byPid = new Map();
|
|
318
|
+
for (const line of psOutput.split('\n')) {
|
|
319
|
+
const pid = line.match(/^\s*(\d+)\s/);
|
|
320
|
+
if (!pid)
|
|
321
|
+
continue;
|
|
322
|
+
const ws = line.match(/\bCMUX_WORKSPACE_ID=([A-Za-z0-9-]+)/);
|
|
323
|
+
if (ws)
|
|
324
|
+
byPid.set(parseInt(pid[1], 10), ws[1].toUpperCase());
|
|
325
|
+
}
|
|
326
|
+
return byPid;
|
|
327
|
+
}
|
|
328
|
+
function runPlain(bin, args, timeoutMs = 3000) {
|
|
329
|
+
return new Promise((resolve) => {
|
|
330
|
+
(0, child_process_1.execFile)(bin, args, { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
|
|
331
|
+
resolve(err ? '' : stdout);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
// A process's environment is fixed at exec time, so a pid only ever needs
|
|
336
|
+
// looking up once. Keyed with procStart as well so a recycled pid can't
|
|
337
|
+
// inherit the previous process's answer. null means "checked, not a cmux
|
|
338
|
+
// process" — cached too, so those aren't re-probed on every poll.
|
|
339
|
+
const workspaceEnvCache = new Map();
|
|
340
|
+
const envKey = (r) => `${r.pid}:${r.procStart ?? ''}`;
|
|
341
|
+
async function liveWorkspaceToSession() {
|
|
342
|
+
const live = loadSessionRegistry().filter((r) => isProcessAlive(r.pid));
|
|
343
|
+
const result = new Map();
|
|
344
|
+
const unknown = [];
|
|
345
|
+
for (const r of live) {
|
|
346
|
+
const key = envKey(r);
|
|
347
|
+
if (workspaceEnvCache.has(key)) {
|
|
348
|
+
const ws = workspaceEnvCache.get(key);
|
|
349
|
+
if (ws)
|
|
350
|
+
result.set(ws, r.sessionId);
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
unknown.push(r);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (unknown.length) {
|
|
357
|
+
// One ps for every new pid at once, not one spawn per session.
|
|
358
|
+
const out = await runPlain('ps', ['eww', '-p', unknown.map((r) => r.pid).join(',')]);
|
|
359
|
+
const byPid = parseWorkspaceEnv(out);
|
|
360
|
+
for (const r of unknown) {
|
|
361
|
+
const ws = byPid.get(r.pid) ?? null;
|
|
362
|
+
workspaceEnvCache.set(envKey(r), ws);
|
|
363
|
+
if (ws)
|
|
364
|
+
result.set(ws, r.sessionId);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Drop entries for processes that are gone, so a long-lived server doesn't
|
|
368
|
+
// accumulate one per session ever started.
|
|
369
|
+
const alive = new Set(live.map(envKey));
|
|
370
|
+
for (const key of workspaceEnvCache.keys()) {
|
|
371
|
+
if (!alive.has(key))
|
|
372
|
+
workspaceEnvCache.delete(key);
|
|
373
|
+
}
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
let cachedLiveWorkspaces = null;
|
|
377
|
+
async function liveWorkspaceToSessionCached() {
|
|
378
|
+
if (cachedLiveWorkspaces && Date.now() - cachedLiveWorkspaces.at < 5_000) {
|
|
379
|
+
return cachedLiveWorkspaces.value;
|
|
380
|
+
}
|
|
381
|
+
const value = await liveWorkspaceToSession();
|
|
382
|
+
cachedLiveWorkspaces = { value, at: Date.now() };
|
|
383
|
+
return value;
|
|
384
|
+
}
|
|
263
385
|
async function findLiveWorkspaceForSession(sessionId) {
|
|
264
386
|
const mapped = loadResumeMap()[sessionId];
|
|
265
387
|
if (!mapped)
|
package/dist/dashboard.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.DEFAULT_PANE_COUNT = exports.PANE_COUNTS = void 0;
|
|
|
4
4
|
exports.timeAgo = timeAgo;
|
|
5
5
|
exports.paneStatus = paneStatus;
|
|
6
6
|
exports.renderPaneBody = renderPaneBody;
|
|
7
|
+
exports.paneTitle = paneTitle;
|
|
7
8
|
exports.waitBadgeHtml = waitBadgeHtml;
|
|
8
9
|
exports.generateDashboard = generateDashboard;
|
|
9
10
|
const template_assets_1 = require("./template-assets");
|
package/dist/discover.js
CHANGED
|
@@ -38,6 +38,7 @@ exports.decodeDirName = decodeDirName;
|
|
|
38
38
|
exports.listProjects = listProjects;
|
|
39
39
|
exports.listSessions = listSessions;
|
|
40
40
|
exports.listRecentSessions = listRecentSessions;
|
|
41
|
+
exports.findRecentSessionsByIds = findRecentSessionsByIds;
|
|
41
42
|
exports.readCwdFromSession = readCwdFromSession;
|
|
42
43
|
exports.findSessionForCwd = findSessionForCwd;
|
|
43
44
|
const fs = __importStar(require("fs"));
|
|
@@ -232,6 +233,32 @@ async function listRecentSessions(limit) {
|
|
|
232
233
|
projectName: top[i].scan.name,
|
|
233
234
|
}));
|
|
234
235
|
}
|
|
236
|
+
// Locate specific sessions by id. scanProjects only stats filenames, so this
|
|
237
|
+
// parses just the matched files — unlike listRecentSessions, which parses every
|
|
238
|
+
// file in its window (getSessionPreview reads a session end to end to total its
|
|
239
|
+
// tokens). Callers that know which ids they want should use this: a session can
|
|
240
|
+
// sit waiting for you while other projects churn past it, so it is not
|
|
241
|
+
// necessarily inside any "most recent N" window.
|
|
242
|
+
async function findRecentSessionsByIds(ids) {
|
|
243
|
+
const wanted = new Set(ids);
|
|
244
|
+
if (!wanted.size)
|
|
245
|
+
return [];
|
|
246
|
+
const scans = await scanProjects();
|
|
247
|
+
const hits = [];
|
|
248
|
+
for (const scan of scans) {
|
|
249
|
+
for (const f of scan.files) {
|
|
250
|
+
if (wanted.has(f.file.replace(/\.jsonl$/, ''))) {
|
|
251
|
+
hits.push({ file: path.join(scan.dir, f.file), scan });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const previews = await Promise.all(hits.map((h) => getSessionPreview(h.file)));
|
|
256
|
+
return previews.map((s, i) => ({
|
|
257
|
+
...s,
|
|
258
|
+
projectRawName: hits[i].scan.rawName,
|
|
259
|
+
projectName: hits[i].scan.name,
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
235
262
|
function readCwdFromSession(filePath) {
|
|
236
263
|
return new Promise((resolve) => {
|
|
237
264
|
const rl = readline.createInterface({
|
package/dist/html-generator.js
CHANGED
|
@@ -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
|
|
354
|
+
? `<div class="detail-backlink"><a href="${(0, util_1.escapeHtml)(backUrl)}">← Back to sessions</a> | <a href="/">Dashboard</a> | <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="
|
|
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">▲</button>
|
|
403
|
+
<span class="msg-pager-count" id="msgPagerCount">–</span>
|
|
404
|
+
<button type="button" class="msg-pager-btn" data-dir="1" aria-label="Next message of yours">▼</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:
|
|
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:
|
|
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
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
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 <=
|
|
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
|
}
|
package/dist/template-assets.js
CHANGED
|
@@ -663,8 +663,11 @@ function getAppJS() {
|
|
|
663
663
|
});
|
|
664
664
|
}
|
|
665
665
|
|
|
666
|
-
// Keyboard navigation: j/k to move between user messages
|
|
667
|
-
|
|
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
|
+
"version": "0.5.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"
|