cctop 0.23.3

An htop-like terminal monitor for AI coding agent sessions on Linux (Claude Code, Codex, Cursor, Devin, Gemini CLI, OpenCode, Pi, Windsurf)
<title>cctop</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<!-- theme-color follows the dark --bg, the scheme a phone is most likely to
     be showing this page in; favicon and manifest are plain same-origin
     routes, so they carry no token of their own. -->
<meta name="theme-color" content="#16151a">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="manifest" href="/manifest.webmanifest">
<style>__CCTOP_CSS__
  .totals { display: flex; gap: 18px; flex-wrap: wrap; align-items: baseline; }
  .totals b { font-family: var(--mono); font-weight: 600; }
  .totals .k { color: var(--faint); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }

  /* How much of each provider's subscription window is already gone — the
     same question the spend totals answer, asked of the plan rather than the
     card. A server without the route leaves it empty, and empty collapses to
     nothing rather than holding a blank line open. */
  .quota {
    flex-basis: 100%; display: flex; gap: 4px 14px; flex-wrap: wrap;
    align-items: center; font-size: 11px; color: var(--faint);
  }
  .quota:empty { display: none; }
  .quota .qp { display: inline-flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }
  .quota .qwho { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
  .quota .qw { white-space: nowrap; }
  .quota .qw b { font-family: var(--mono); font-variant-numeric: tabular-nums; font-weight: 600; color: var(--dim); }
  .quota .ctx { width: 28px; height: 4px; }

  .controls { display: flex; gap: 8px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
  input[type=search] {
    flex: 1 1 200px; min-width: 0; padding: 7px 11px; font: inherit; font-size: 14px;
    background: var(--panel); color: var(--ink);
    border: 1px solid var(--line); border-radius: 8px;
  }
  input[type=search]:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
  button {
    padding: 7px 11px; font: inherit; font-size: 13px; cursor: pointer;
    background: var(--panel); color: var(--dim);
    border: 1px solid var(--line); border-radius: 8px;
  }
  button[aria-pressed=true] { color: var(--accent); border-color: var(--accent); }
  select {
    padding: 7px 9px; font: inherit; font-size: 13px; cursor: pointer;
    background: var(--panel); color: var(--dim);
    border: 1px solid var(--line); border-radius: 8px;
  }
  /* Starting a fresh agent from the page — the launcher the terminal has on
     `n`, carried to the one place a phone can reach. */
  form.launch { display: flex; gap: 8px; align-items: center; margin: 0; }
  form.launch[hidden] { display: none; }
  form.launch input[type=text] {
    width: 180px; padding: 7px 11px; font: inherit; font-size: 14px;
    background: var(--panel); color: var(--ink);
    border: 1px solid var(--line); border-radius: 8px;
  }
  form.launch .said { font-size: 12px; }

  /* Sessions waiting on a person get their own block at the top. The whole
     reason to look at this on a phone is to find them. */
  .attention { border-color: var(--amber); margin-bottom: 16px; }
  .attention h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .06em;
                  color: var(--amber); padding: 11px 14px 0; }

  .rows { display: block; }
  .row {
    display: grid; gap: 2px 10px; padding: 11px 14px; border-top: 1px solid var(--line);
    grid-template-columns: 8px minmax(0, 1fr) auto;
    text-decoration: none; color: inherit; cursor: pointer;
  }
  .row:first-of-type { border-top: 0; }
  .row:hover, .row:focus-visible { background: color-mix(in srgb, var(--accent) 7%, transparent); }
  /* The keyboard mark sits a shade above hover rather than replacing it:
     the pointer and the selection are separate claims about a row, and both
     can be on it at once. */
  .row.sel { background: color-mix(in srgb, var(--accent) 12%, transparent); }
  .row.sel:hover, .row.sel:focus-visible { background: color-mix(in srgb, var(--accent) 18%, transparent); }
  .row .dot { margin-top: 7px; }
  .row .name { min-width: 0; font-weight: 500; }
  .row .name .title { color: var(--dim); font-weight: 400; }
  .row .name .pin { color: var(--faint); font-weight: 400; margin-right: 4px; }
  /* Bulk-selected, as distinct from keyboard-selected: the mark is a ✓ and a
     bar on the edge rather than a background, so a row can carry both at once
     and still read as two different claims. */
  .row .name .tick { color: var(--accent); font-weight: 400; margin-right: 4px; }
  .row.picked { box-shadow: inset 3px 0 0 var(--accent); }
  /* "Something new since you opened the chat" — cctop-seen is report.html's
     record, read here and never written. */
  .row .name .newdot {
    display: inline-block; width: 6px; height: 6px; border-radius: 50%;
    background: var(--accent); margin-left: 6px; vertical-align: 1px;
  }
  .row .meta { grid-column: 2; display: flex; gap: 8px; flex-wrap: wrap; font-size: 12px; color: var(--faint); }
  /* The transcript line a search matched, quoted under the row's own metadata
     on a line of its own — the row matched nothing visible, so this is the
     only place that says why it is on screen at all. */
  .row .meta .snip { flex: 1 1 100%; min-width: 0; }
  .row .figures { grid-row: 1 / span 2; grid-column: 3; text-align: right; font-family: var(--mono);
                  font-variant-numeric: tabular-nums; font-size: 13px; white-space: nowrap; }
  .row .figures .sub { font-size: 11px; color: var(--faint); }
  .trunc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

  /* The context bar, the one figure that is a proportion rather than a count. */
  .ctx { display: inline-block; width: 46px; height: 5px; border-radius: 3px;
         background: var(--line); overflow: hidden; vertical-align: middle; }
  .ctx i { display: block; height: 100%; background: var(--dim); }

  /* A session that is waiting, with the box for answering it. The row stays a
     link to the whole session; the form sits under it rather than inside it,
     because a form inside an anchor is neither valid nor operable. */
  .wanting { position: relative; border-top: 1px solid var(--line); }
  .wanting:first-child { border-top: 0; }
  /* The extra right padding is the gutter the dismiss button sits in. It
     cannot be inside the row — a button inside an anchor is not valid — so
     the row narrows a little to make room for it instead. */
  .wanting .row { border-top: 0; padding-right: 38px; }
  .wanting .dismiss {
    position: absolute; top: 9px; right: 8px; padding: 3px 7px;
    font-size: 14px; line-height: 1; border-radius: 6px;
    color: var(--faint); background: transparent; border-color: transparent;
  }
  .wanting .dismiss:hover { color: var(--ink); border-color: var(--line); }
  form.say { display: flex; gap: 8px; padding: 0 14px 11px 32px; }
  form.say input {
    flex: 1 1 auto; min-width: 0; font: inherit; font-size: 13px; padding: 6px 9px;
    border-radius: 7px; border: 1px solid var(--line); background: var(--bg); color: var(--ink);
  }
  form.say input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
  form.say button {
    font: inherit; font-size: 13px; padding: 0 13px; border-radius: 7px; cursor: pointer;
    border: 1px solid var(--accent); background: var(--accent); color: var(--panel);
  }
  form.say button:disabled { opacity: .5; cursor: default; }
  /* The one-word replies. Quieter than Send — bordered rather than filled —
     because they answer for you, and the filled button stays the deliberate
     act. */
  form.say .chip {
    font: inherit; font-size: 12px; padding: 0 10px; border-radius: 7px; cursor: pointer;
    border: 1px solid var(--line); background: var(--panel); color: var(--dim);
    flex: 0 0 auto;
  }
  .wanting .asked { padding: 0 14px 8px 32px; min-width: 0; }
  .wanting .asked code {
    display: block; font-size: 12px; padding: 5px 9px; border-radius: 7px;
    background: var(--bg); border: 1px solid var(--line); color: var(--ink);
    white-space: pre-wrap; overflow-wrap: anywhere;
  }
  form.say .said { font-size: 12px; align-self: center; color: var(--faint); }
  form.say .said.bad { color: var(--red); }
  .ctx i.hot { background: var(--amber); }
  .ctx i.full { background: var(--red); }

  /* The bulk-select affordance: a checkbox in a gutter the row's left padding
     widens to make, so it never sits on the status dot. Quiet until hovered
     or checked — marking is the exception, not the default. Drawn only when
     the serve may act; a read-only page never gets the wrappers. */
  .pickrow { position: relative; }
  #rows .pickrow { border-top: 1px solid var(--line); }
  #rows .pickrow:first-of-type { border-top: 0; }
  .pickrow .row { border-top: 0; padding-left: 30px; }
  .pickrow .pick {
    position: absolute; left: 9px; top: 14px; margin: 0;
    accent-color: var(--accent); opacity: .4; cursor: pointer;
  }
  .pickrow:hover .pick, .pickrow .pick:checked, .pickrow .pick:focus-visible { opacity: 1; }

  /* Transcript-search results stand in for the table while the card is open.
     The table is only hidden, so closing puts back exactly what was there. */
  .find { position: relative; }
  .find h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .06em;
             color: var(--faint); padding: 11px 14px 0; }
  .find .dismiss {
    position: absolute; top: 9px; right: 8px; padding: 3px 7px;
    font-size: 14px; line-height: 1; border-radius: 6px;
    color: var(--faint); background: transparent; border-color: transparent;
  }
  .find .dismiss:hover { color: var(--ink); border-color: var(--line); }
  .empty.bad { color: var(--red); }

  /* The bulk bar: nothing while the set is empty, then the few verbs the set
     answers to, between the table and the footer. */
  .bulk {
    display: flex; gap: 6px 10px; align-items: center; flex-wrap: wrap;
    margin-top: 12px; font-size: 13px; color: var(--dim);
  }
  .bulk[hidden] { display: none; }
  .bulk .sep { color: var(--faint); }
  .bulk form { display: flex; gap: 8px; flex: 1 1 260px; min-width: 0; margin: 0; }
  .bulk form[hidden] { display: none; }
  .bulk input[type=text] {
    flex: 1 1 auto; min-width: 0; font: inherit; font-size: 13px; padding: 6px 9px;
    border-radius: 7px; border: 1px solid var(--line); background: var(--panel); color: var(--ink);
  }
  .bulk input[type=text]:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
  .bulk button:disabled { opacity: .5; cursor: default; }
  .bulk .said { font-size: 12px; }
  .bulk .said.bad { color: var(--red); }

  /* A phone gets the same page: the controls wrap, the table stacks its
     figures under the metadata instead of squeezing the name column, and the
     answer box lets its input take a line of its own. */
  @media (max-width: 700px) {
    .wrap { padding: 12px 10px 48px; }
    .controls { gap: 6px; }
    form.launch { flex-basis: 100%; flex-wrap: wrap; }
    form.launch input[type=text] { flex: 1 1 auto; width: auto; min-width: 0; }
    .row { grid-template-columns: 8px minmax(0, 1fr); }
    .row .figures {
      grid-row: auto; grid-column: 2; text-align: left; white-space: normal;
      display: flex; gap: 4px 14px; flex-wrap: wrap; margin-top: 3px;
    }
    form.say { flex-wrap: wrap; }
    form.say input { flex: 1 1 150px; }
  }

  footer { margin-top: 20px; font-size: 12px; color: var(--faint);
           display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
  footer .hint { font-size: 11px; }
  .live { display: inline-flex; align-items: center; gap: 6px; }
</style>
<!-- Synchronous, before anything paints: a stored theme choice has to be on
     <html> already, or a reader who picked dark sees a light frame first. -->
<script>__CCTOP_THEME_JS__</script>

<div class="wrap">
  <header class="top">
    <h1>cctop<span class="v mono">__CCTOP_VERSION__</span></h1>
    <div class="spacer"></div>
    <div class="totals" id="totals"></div>
    <div class="quota" id="quota"></div>
  </header>

  <div id="banners"></div>

  <div class="controls">
    <input type="search" id="filter" placeholder="Filter on project, model, branch, title…" autocomplete="off" spellcheck="false"
           title="Type to filter the table · Enter to search every transcript">
    <button id="gsearch" title="Search every transcript for the filter text">Search transcripts</button>
    <button id="running" aria-pressed="false" title="Show only sessions with a live process">Running</button>
    <button id="bell" aria-pressed="false" title="Notify when a session starts waiting on you">Notify</button>
    <select id="sort" title="Order the table">
      <option value="recent">Recent</option>
      <option value="cost">Cost</option>
      <option value="tokens">Tokens</option>
      <option value="context">Context</option>
    </select>
    <form id="launch" class="launch" hidden>
      <select id="launch-agent" title="Which agent to start"></select>
      <input id="launch-cwd" type="text" placeholder="directory — default ~" autocomplete="off" spellcheck="false">
      <button type="submit" title="Start the agent, in the multiplexer">Start</button>
      <span id="launch-said" class="said"></span>
    </form>
  </div>

  <section class="card find" id="find" hidden>
    <h2 id="find-heading"></h2>
    <button class="dismiss" id="find-close" type="button" title="Back to the session table"
            aria-label="Back to the session table">×</button>
    <div class="rows" id="find-rows"></div>
  </section>

  <div id="tableview">
    <section class="card attention" id="attention" hidden>
      <h2 id="attention-heading"></h2>
      <div class="rows" id="attention-rows"></div>
    </section>

    <section class="card">
      <div class="rows" id="rows"></div>
      <div class="empty" id="empty">Waiting for the first refresh…</div>
    </section>
  </div>

  <div class="bulk" id="bulk" hidden>
    <span id="bulk-count"></span>
    <span class="sep">·</span>
    <button id="bulk-send" type="button" title="Type one line at every selected session">send…</button>
    <span class="sep">·</span>
    <button id="bulk-resume" type="button" title="Resume every selected session">resume</button>
    <span class="sep">·</span>
    <button id="bulk-clear" type="button" title="Empty the selection">clear</button>
    <form id="bulk-form" hidden>
      <input id="bulk-text" type="text" maxlength="4000" autocomplete="off" spellcheck="false"
             placeholder="One line, sent to each selected session">
      <button type="submit">Send</button>
    </form>
    <span class="said" id="bulk-said"></span>
  </div>

  <footer>
    <span class="live"><span class="dot idle" id="link"></span><span id="link-text">connecting…</span></span>
    <a id="analytics-link" href="/analytics">analytics</a>
    <span class="spacer"></span>
    <span id="counts"></span>
    <span class="hint" id="keys">j/k select · Enter opens · p pins · / filters · Enter in the filter searches transcripts</span>
    <a id="insight-optimize" href="/insight/optimize">optimize</a>
    <a id="insight-compare" href="/insight/compare">compare</a>
  </footer>
</div>

<script>
__CCTOP_DASHBOARD_JS__</script>