Show a running token list in the /chat top bar

589a7039c554 · Devin · · parent ed2452ab44e5

Show a running token list in the /chat top bar

The console reported token counts only per turn, so an operator could not see
what a conversation had consumed without adding the turns up by hand. The top
bar now carries a running list, anchored right, that sums the provider-reported
input, output, reasoning, and cached counts across the conversation and
recounts on every transcript assign. A kind no turn reported stays off the
list, and a provider that omits total_tokens falls back to input plus output.

Closes #101
Closes
#101

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified lib/openagents_web/live/chat_console_live.ex
  • modified test/openagents_web/live/chat_console_test.exs

Diff

2 files changed, +84 -6

lib/openagents_web/live/chat_console_live.ex modified +77 -6

@@ -25,6 +25,8 @@ defmodule OpenAgentsWeb.ChatConsoleLive do

25 25
    "Write a short status update for the current fleet work."
26 26
  ]
27 27
28
  @token_kinds [input: "Input", output: "Output", reasoning: "Reasoning", cached: "Cached"]
29
28 30
  @reasoning_options [
29 31
    {"Reasoning off", "none"},
30 32
    {"Minimal reasoning", "minimal"},

@@ -81,7 +83,7 @@ defmodule OpenAgentsWeb.ChatConsoleLive do

81 83
     |> assign(:reasoning_options, @reasoning_options)
82 84
     |> assign(:model_label, OpenRouter.model_label())
83 85
     |> assign(:suggestions, @suggestions)
84
     |> assign(:messages, messages)
86
     |> assign_messages(messages)
85 87
     |> assign(:assistant_response, nil)
86 88
     |> assign(:assistant_reasoning, nil)
87 89
     |> assign(:assistant_tool_calls, [])

@@ -216,6 +218,23 @@ defmodule OpenAgentsWeb.ChatConsoleLive do

216 218
          <p id="chat-console-operator-notice" class="text-muted-foreground text-xs">
217 219
            Operator-only console. Prompts reach {@model_label} through the server.
218 220
          </p>
221
          <dl
222
            id="chat-console-token-list"
223
            class="ml-auto flex shrink-0 flex-wrap items-baseline justify-end gap-x-3 gap-y-1 text-xs"
224
            aria-label="Tokens this conversation used"
225
          >
226
            <div :if={@conversation_tokens == []} class="text-muted-foreground">
227
              No tokens yet
228
            </div>
229
            <div
230
              :for={{key, label, count} <- @conversation_tokens}
231
              id={"chat-console-token-#{key}"}
232
              class="flex items-baseline gap-1"
233
            >
234
              <dt class="text-muted-foreground">{label}</dt>
235
              <dd class="font-medium tabular-nums">{format_count(count)}</dd>
236
            </div>
237
          </dl>
219 238
        </div>
220 239
221 240
        <div class="flex min-h-0 flex-1 px-4">

@@ -395,7 +414,7 @@ defmodule OpenAgentsWeb.ChatConsoleLive do

395 414
396 415
  defp reset_stream(socket) do
397 416
    socket
398
    |> assign(:messages, AccountTurns.list_messages(socket.assigns.current_user))
417
    |> assign_messages(AccountTurns.list_messages(socket.assigns.current_user))
399 418
    |> assign(:assistant_response, nil)
400 419
    |> assign(:assistant_reasoning, nil)
401 420
    |> assign(:assistant_tool_calls, [])

@@ -442,6 +461,58 @@ defmodule OpenAgentsWeb.ChatConsoleLive do

442 461
443 462
  defp context_evidence(_message), do: nil
444 463
464
  # The top bar counts the whole conversation, so every assign of the transcript
465
  # recounts it and the running list never trails the messages it describes.
466
  defp assign_messages(socket, messages) do
467
    socket
468
    |> assign(:messages, messages)
469
    |> assign(:conversation_tokens, conversation_tokens(messages))
470
  end
471
472
  # Provider-reported counts only, summed across the turns that reported them. A
473
  # kind no turn reported stays off the list instead of reading as a zero.
474
  defp conversation_tokens(messages) do
475
    usages = messages |> Enum.map(&Map.get(&1, :usage)) |> Enum.filter(&is_map/1)
476
477
    case usages do
478
      [] ->
479
        []
480
481
      usages ->
482
        kinds =
483
          for {key, label} <- @token_kinds,
484
              count = sum_tokens(usages, key),
485
              do: {key, label, count}
486
487
        kinds ++ [{:total, "Total", conversation_total(usages)}]
488
    end
489
  end
490
491
  defp sum_tokens(usages, key) do
492
    case usages |> Enum.map(&Map.get(&1, key)) |> Enum.reject(&is_nil/1) do
493
      [] -> nil
494
      counts -> Enum.sum(counts)
495
    end
496
  end
497
498
  defp conversation_total(usages), do: Enum.sum(Enum.map(usages, &turn_total/1))
499
500
  # A provider that leaves `total_tokens` out still reports the two sides of it.
501
  defp turn_total(%{total: total}) when is_integer(total), do: total
502
  defp turn_total(usage), do: (usage.input || 0) + (usage.output || 0)
503
504
  defp format_count(count) do
505
    count
506
    |> Integer.to_string()
507
    |> String.to_charlist()
508
    |> Enum.reverse()
509
    |> Enum.chunk_every(3)
510
    |> Enum.intersperse(~c",")
511
    |> Enum.concat()
512
    |> Enum.reverse()
513
    |> List.to_string()
514
  end
515
445 516
  defp usage_text(usage) do
446 517
    [
447 518
      usage.input && "Input #{usage.input}",

@@ -460,7 +531,7 @@ defmodule OpenAgentsWeb.ChatConsoleLive do

460 531
         socket
461 532
         |> assign(:form, composer_form(reasoning))
462 533
         |> assign(:last_prompt, message)
463
         |> assign(:messages, AccountTurns.list_messages(socket.assigns.current_user))
534
         |> assign_messages(AccountTurns.list_messages(socket.assigns.current_user))
464 535
         |> assign(:assistant_response, "")
465 536
         |> assign(:assistant_reasoning, nil)
466 537
         |> assign(:assistant_tool_calls, [])

@@ -476,9 +547,9 @@ defmodule OpenAgentsWeb.ChatConsoleLive do

476 547
        {:noreply,
477 548
         socket
478 549
         |> assign(:form, composer_form(reasoning, message))
479
         |> update(:messages, fn messages ->
480
           messages ++ [user_message(id, message), failed_message(id, reason)]
481
         end)}
550
         |> assign_messages(
551
           socket.assigns.messages ++ [user_message(id, message), failed_message(id, reason)]
552
         )}
482 553
    end
483 554
  end
484 555
test/openagents_web/live/chat_console_test.exs modified +7

@@ -43,6 +43,8 @@ defmodule OpenAgentsWeb.ChatConsoleTest do

43 43
    assert has_element?(view, "#chat-console-empty")
44 44
    assert has_element?(view, "#chat-console-model", "Ox Alpha")
45 45
    assert has_element?(view, "#chat-console-operator-notice", "Operator-only console")
46
    assert has_element?(view, "#chat-console-token-list", "No tokens yet")
47
    refute has_element?(view, "#chat-console-token-total")
46 48
    assert has_element?(view, "#chat-console-suggestions")
47 49
    assert has_element?(view, "#chat-console-suggestion-0")
48 50
    assert has_element?(view, ~s(a[href="/sarah"]))

@@ -130,6 +132,11 @@ defmodule OpenAgentsWeb.ChatConsoleTest do

130 132
    assert has_element?(view, "#chat-console-usage-#{run_id}", "Output 8")
131 133
    assert has_element?(view, "#chat-console-usage-#{run_id}", "Reasoning 3")
132 134
    assert has_element?(view, "#chat-console-usage-#{run_id}", "Cached 4")
135
    assert has_element?(view, "#chat-console-token-input", "24")
136
    assert has_element?(view, "#chat-console-token-output", "8")
137
    assert has_element?(view, "#chat-console-token-reasoning", "3")
138
    assert has_element?(view, "#chat-console-token-cached", "4")
139
    assert has_element?(view, "#chat-console-token-total", "32")
133 140
    refute has_element?(view, "#chat-console-evidence-#{run_id}")
134 141
  end
135 142

This page updates live while a promote is in flight · changelog