Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Token Meter reads local agent traces, calculates clearly labeled usage estimates
- Persist machine-wide settings through the existing atomic JSON-write path and action-token-protected HTTP endpoints.
- New settings require validation, idempotent writes, migration behavior, and tests.
- Preserve legacy hash routes and stored preferences when changing navigation or native settings.
- Keep the top-level dashboard order `Sessions → Spend → Models → Subagents → Efficiency → Work → Git → Learn → Tools → Settings`. The top-level Subagents page owns only role economics. Sessions contains `Current sessions`, `All sessions`, `Compare`, and `Subagents`; Compare (`#sessions-compare`) owns side-by-side comparison of up to four selected sessions, and Subagents owns child-run `Sessions` and `Issues` investigation. Preserve both `#subagents` and `#sessions-subagents` routes.
- Keep the top-level dashboard order `Sessions → Spend → Models → Subagents → Efficiency → Work → Git → Learn → Tools → Settings`. The top-level Subagents page owns only role economics. Sessions contains `Current sessions`, `All sessions`, `Compare`, `By branch`, and `Subagents`; Compare (`#sessions-compare`) owns side-by-side comparison of up to four selected sessions, By branch (`#sessions-branches`) owns spend grouped by trace branch, and Subagents owns child-run `Sessions` and `Issues` investigation. Preserve both `#subagents` and `#sessions-subagents` routes.
- Global is not a dashboard surface. Keep cross-session aggregation as shared backend data for Sessions All, Daily, Models, Tools, Efficiency, MCP, and the menu bar.
- Keep the complete machine-wide monthly budget status and editor on the Spend page (`#spend-budgets`). The native companion deep-links there; preserve `#settings-budgets` and `#budgets` as compatibility redirects.
- Use macOS labels such as `⌥`, never `Alt`, in user-facing copy.
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,16 @@ It measures effort, not answer quality.
<img src="docs/talk/img/live-compare.jpg" alt="Compare sessions view with three runs of the same prompt and cost insights" width="900">
</p>

### By branch: what did each branch cost?

Sessions › By branch groups spend by the Git branch each session ran on, with
its apps, sessions, days active, and push status. Filter by app, project, time,
or status, search by branch name or issue key, and open a branch to see its
sessions. A branch chip on cards, rows, and the session header opens the same
view. The branch comes from each app's own session files (Claude Code and
Codex record it). Apps that do not record a branch show as "No branch
recorded".

### Spend: am I on track this month?

Spend by day, platform, project, and session, with a monthly budget and a
Expand Down Expand Up @@ -209,6 +219,18 @@ of work (features, fixes, refactoring, docs, and more) from their messages,
labeled by the local model. Local `git` only; it never contacts a remote. A
mechanical signal, not a productivity score.

Known limit: Token Meter keeps one default branch per repository. With several
remotes whose default branches differ, another remote's default can be listed as
a feature branch.

Git › Branches scores feature branches: the median cost to ship one, the share
of spend on branches left unpushed for a week, the spend on rework after a
branch's first push (an estimate), and branches that cost three times your
typical spend per commit. Push evidence comes from this Mac's Git reflogs and
is matched by a salted key, so the Git ledger never stores a branch name. Branch
names stay on the local dashboard and are never sent anywhere; Token Meter does
not contact GitHub, GitLab, or an issue tracker.

<p align="center">
<img src="docs/talk/img/live-git.jpg" alt="Git page with spend per 1K pushed lines and push yield" width="900">
</p>
Expand Down
2 changes: 1 addition & 1 deletion dashboard/js/all-sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ function allSessionsView(all,{showChildren=false,app='',project='',rangeStart=0,
if(rangeEnd&&(s.mtime||0)>=rangeEnd)return false;
return true;
};
const textMatch=s=>!query||[s.title,s.project,s.provider,s.label,s.child_agent_role,(s.models||[]).join(' ')].join(' ').toLowerCase().includes(query);
const textMatch=s=>!query||[s.title,s.project,s.provider,s.label,s.child_agent_role,s.branch,(s.models||[]).join(' ')].join(' ').toLowerCase().includes(query);
const matching=(all||[]).filter(s=>baseMatch(s)&&textMatch(s));
const children=matching.filter(s=>s.is_child_session);
let rows=matching;
Expand Down
393 changes: 393 additions & 0 deletions dashboard/js/branches.js

Large diffs are not rendered by default.

19 changes: 12 additions & 7 deletions dashboard/js/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ function costTipHtml(s,tok){
let LATEST=null, CURRENT=null, pinned=null, sessionRequest=0, selectedPollId='', RUNTIME_CATALOG={};
// Declared early: the first header render looks up trace titles before the All sessions code runs.
let allSessionInventory=null,allSessionInventoryAt=0,allSessionsRequest=null,renderedAllSessions=new Map(),renderedAllSessionActions={},forceAllSessionRowRefresh=false;
let branchIndexSource=null,branchIndex=new Map(),branchInventoryRequestedAt=0;
const LIVE_XSESSION={state:{snapshot:null,revision:''},session:{snapshot:null,revision:''}};
let AGENT_ACCESS=null, pendingAgentAccess=null, agentAccessLoading=false, agentAccessRechecking=false;
const CURRENT_SESSION_ORDER_KEY='tm_current_session_order_v1';
Expand Down Expand Up @@ -309,7 +310,7 @@ function openSelectedSessionRoute(route='summary'){
}
setHashRoute(route);
}
const SESSION_SCOPES=['current','all','compare','subagents'];
const SESSION_SCOPES=['current','all','compare','branches','subagents'];
let sessionScope='current',sessionReturnScope='current';
const COMPARE_MAX=4,COMPARE_LETTERS='ABCD',COMPARE_COLORS=THEME.compare;
function loadCompareIds(){
Expand All @@ -322,10 +323,10 @@ let compareIds=loadCompareIds(),compareBaseline=localStorage.getItem('tm_compare
let compareChartMetric=localStorage.getItem('tm_compare_chart')==='tokens'?'tokens':'cost';
let compareMissing=[],compareData=null,compareError='',compareLoading=false,compareRequestSeq=0,compareLoadedKey='',compareLoadedAt=0,compareNotice='';
const compareTitles=new Map();
let compareUiReady=false;
let compareUiReady=false,branchUiReady=false;
function normalizeSessionScope(scope){return SESSION_SCOPES.includes(scope)?scope:'current';}
function sessionScopeRoute(scope){scope=normalizeSessionScope(scope);return scope==='all'?'sessions-all':scope==='compare'?'sessions-compare':scope==='subagents'?'sessions-subagents':'sessions';}
function sessionScopeLabel(scope){return {all:'All sessions',compare:'Compare',subagents:'Subagents'}[normalizeSessionScope(scope)]||'Current sessions';}
function sessionScopeRoute(scope){scope=normalizeSessionScope(scope);return scope==='all'?'sessions-all':scope==='compare'?'sessions-compare':scope==='branches'?'sessions-branches':scope==='subagents'?'sessions-subagents':'sessions';}
function sessionScopeLabel(scope){return {all:'All sessions',compare:'Compare',branches:'By branch',subagents:'Subagents'}[normalizeSessionScope(scope)]||'Current sessions';}
function sessionListBackAvailable(scope){return !routedSessionId()&&(location.hash||'').replace(/^#/,'')===sessionScopeRoute(scope);}
function renderSessionBack(){
const label=sessionScopeLabel(sessionReturnScope);
Expand All @@ -341,17 +342,20 @@ function sessionScopeCopy(scope){
return {
all:['Local session history.','Search, filter, and compare local session history.'],
compare:['Selected sessions side by side.','Compare up to four selected sessions and see which is cheapest and fastest.'],
branches:['Spend grouped by the Git branch each session ran on.','Branch names come from each app\u2019s session files. Select a branch to see its sessions.'],
subagents:['Investigate child-agent spend, completion, and role patterns.','Filter child agents, inspect incomplete work, and open their parent sessions.'],
}[scope]||['Last 30 minutes','Select a card to inspect it. Drag a card or press ⌥ and an arrow key to reorder.'];
}
function renderSessionScope(){
const all=sessionScope==='all',subagents=sessionScope==='subagents',compare=sessionScope==='compare',overview=currentPanel==='sessions';
const all=sessionScope==='all',subagents=sessionScope==='subagents',compare=sessionScope==='compare',branches=sessionScope==='branches',overview=currentPanel==='sessions';
$('view-session').classList.toggle('sessionAll',all);
$('view-session').classList.toggle('sessionSubagents',subagents);
$('view-session').classList.toggle('sessionCompare',compare);
$('current-sessions-overview').hidden=all||subagents||compare;
$('view-session').classList.toggle('sessionBranches',branches);
$('current-sessions-overview').hidden=all||subagents||compare||branches;
$('all-session-history').hidden=!all;
$('session-compare').hidden=!compare;
$('branch-spend').hidden=!branches;
if($('subagent-explorer').parentElement===$('preview-surface-sessions'))$('subagent-explorer').hidden=!subagents;
[...$('session-scope-tabs').querySelectorAll('[data-session-scope]')].forEach(button=>{
const selected=button.dataset.sessionScope===sessionScope;
Expand All @@ -375,6 +379,7 @@ function renderSessionScope(){
loadAllSessions();
}
if(subagents&&LATEST){renderSubagentExplorer(LATEST.xsession?.agent_usage);loadAllSessions();}
if(branchUiReady&&branches&&overview)openBranchSpend();
}
function setSessionScope(scope){
sessionScope=normalizeSessionScope(scope);
Expand All @@ -391,5 +396,5 @@ function openSessionScope(scope,{replace=false}={}){
if(history.pushState)history[replace?'replaceState':'pushState'](null,'',`/#${route}`);
else location.hash=route;
applyHashRoute();
return sessionScope==='all'?allSessionInventory||[]:sessionScope==='compare'?compareData?.sessions||[]:sessionScope==='subagents'?LATEST?.xsession?.agent_usage?.inventory||[]:LATEST?.xsession?.current_sessions||[];
return sessionScope==='all'||sessionScope==='branches'?allSessionInventory||[]:sessionScope==='compare'?compareData?.sessions||[]:sessionScope==='subagents'?LATEST?.xsession?.agent_usage?.inventory||[]:LATEST?.xsession?.current_sessions||[];
}
10 changes: 9 additions & 1 deletion dashboard/js/current-sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -544,11 +544,19 @@ function renderCurrentSessions(state=LATEST){
const hintsHtml=hints.length?`<div class=currentSessionHints>${hints.map(hint=>`<span class=currentSessionHint data-kind="${esc(hint.kind)}" title="${esc(hint.detail)}"><b>${esc(hint.title)}</b><small>${esc(hint.detail)}</small></span>`).join('')}</div>`:'';
const arriving=arrivingIds.has(String(row.id))?' is-arriving':'';
const desktop=desktopSessionTarget(row);
const branch=sessionBranchFor(row),capsHtml=`<span class="currentSessionCaps mono" title="${esc(CAPABILITY_SUMMARY_TIP)}">${esc(capabilitySummaryText(row.capabilities))}</span>`;
const capsLine=branch?`<div class=currentSessionCapsRow>${capsHtml}<span class=currentSessionBranchAnchor aria-hidden=true>${branchChipHtml(branch,{interactive:false})}</span></div>`:capsHtml;
const branchSibling=currentSessionBranchSibling(row,branch);
const desktopLink=desktop?`<a class=currentSessionDesktop href="${esc(desktop.href)}" draggable=false title="${esc(desktop.title)}" aria-label="${esc(`${desktop.label}: ${sessionName}`)}"><svg viewBox="0 0 16 16" aria-hidden=true><path d="M9.5 2.5h4v4M13.5 2.5 7.5 8.5M12 9.5v3a1 1 0 0 1-1 1H3.5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h3"/></svg></a>`:'';
return `<div class=currentSessionSlot${desktop?' data-desktop':''}><button class="card currentSessionCard provider-${esc(provider)} activity-${activity}${arriving}${costAvailable?'':' fieldtip'}" style="--harness:${esc(runtimeHarness(row))}" type=button draggable=true data-current-session-id="${esc(row.id)}" aria-label="${esc(`Open ${sessionName}`)}" aria-description="${esc(cardDescription)}" aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight Alt+ArrowUp Alt+ArrowDown" ${costTipAttrs}><div class=currentSessionTop><div class=currentSessionRuntime>${runtimeMarkSvg(row)}<span>${esc(runtimeLine)}</span><span class=currentSessionProject>${esc(row.project||'No project')}</span></div><div class=currentSessionTopMeta><span class=currentSessionMove><svg class=currentSessionGrip viewBox="0 0 12 16" aria-hidden=true><circle cx="3" cy="3" r="1.2"/><circle cx="9" cy="3" r="1.2"/><circle cx="3" cy="8" r="1.2"/><circle cx="9" cy="8" r="1.2"/><circle cx="3" cy="13" r="1.2"/><circle cx="9" cy="13" r="1.2"/></svg>${currentSessionAge(row)}</span>${cacheRingHtml(row)}<div class="currentSessionStatus ${activity}">${activityLabel}</div></div></div><div class=currentSessionIdentity><h3>${esc(sessionName)}</h3><span class="currentSessionCaps mono" title="${esc(CAPABILITY_SUMMARY_TIP)}">${esc(capabilitySummaryText(row.capabilities))}</span></div><div class=currentSessionHero><div class=currentSessionHeroCost><span class=currentSessionHeroLabel>${costLabel}</span><b class="currentSessionHeroValue mono">${cost}</b></div><div class=currentSessionHeroCompare>${heroScale}${heroCaption?`<span class=readoutCaption>${esc(heroCaption)}</span>`:''}</div></div><div class=currentSessionMetrics><div class="currentSessionMetric metric"><span>Output / $</span><b class=mono>${esc(outputPerDollar)}</b></div><div class="currentSessionMetric metric"><span>Context in use</span><div class=currentSessionContextValue><b class=mono>${esc(context)}</b>${contextSpark.html}</div></div><div class="currentSessionMetric metric"><span>Speed</span><b class=mono>${esc(speed)}</b></div></div>${hintsHtml}</button>${desktopLink}</div>`;
return `<div class=currentSessionSlot${desktop?' data-desktop':''}><button class="card currentSessionCard provider-${esc(provider)} activity-${activity}${arriving}${costAvailable?'':' fieldtip'}" style="--harness:${esc(runtimeHarness(row))}" type=button draggable=true data-current-session-id="${esc(row.id)}" aria-label="${esc(`Open ${sessionName}`)}" aria-description="${esc(cardDescription)}" aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight Alt+ArrowUp Alt+ArrowDown" ${costTipAttrs}><div class=currentSessionTop><div class=currentSessionRuntime>${runtimeMarkSvg(row)}<span>${esc(runtimeLine)}</span><span class=currentSessionProject>${esc(row.project||'No project')}</span></div><div class=currentSessionTopMeta><span class=currentSessionMove><svg class=currentSessionGrip viewBox="0 0 12 16" aria-hidden=true><circle cx="3" cy="3" r="1.2"/><circle cx="9" cy="3" r="1.2"/><circle cx="3" cy="8" r="1.2"/><circle cx="9" cy="8" r="1.2"/><circle cx="3" cy="13" r="1.2"/><circle cx="9" cy="13" r="1.2"/></svg>${currentSessionAge(row)}</span>${cacheRingHtml(row)}<div class="currentSessionStatus ${activity}">${activityLabel}</div></div></div><div class=currentSessionIdentity><h3>${esc(sessionName)}</h3>${capsLine}</div><div class=currentSessionHero><div class=currentSessionHeroCost><span class=currentSessionHeroLabel>${costLabel}</span><b class="currentSessionHeroValue mono">${cost}</b></div><div class=currentSessionHeroCompare>${heroScale}${heroCaption?`<span class=readoutCaption>${esc(heroCaption)}</span>`:''}</div></div><div class=currentSessionMetrics><div class="currentSessionMetric metric"><span>Output / $</span><b class=mono>${esc(outputPerDollar)}</b></div><div class="currentSessionMetric metric"><span>Context in use</span><div class=currentSessionContextValue><b class=mono>${esc(context)}</b>${contextSpark.html}</div></div><div class="currentSessionMetric metric"><span>Speed</span><b class=mono>${esc(speed)}</b></div></div>${hintsHtml}</button>${branchSibling}${desktopLink}</div>`;
}).join(''):(filterView.activitySelection==='all'?currentSessionsEmptyHtml(state?.xsession):currentSessionsFilteredEmptyHtml(filterView.activitySelection));
if(branchUiReady)placeCurrentSessionBranchChips(currentGrid);
if(focusedId)requestAnimationFrame(()=>focusCurrentSessionCard(focusedId));
}
// The card is one button, so its branch chip is a sibling control laid over the capability line.
function currentSessionBranchSibling(row,branch){
return branch?branchChipHtml(branch,{project:row.project||''}):'';
}
function keepCurrentSessionFilterFocusVisible(event){
const button=event.target.closest('button');
if(button)button.scrollIntoView({block:'nearest',inline:'nearest'});
Expand Down
Loading
Loading