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
34 changes: 31 additions & 3 deletions src/components/TaskPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,23 @@
// long job and a quick question can sit side by side under one agent.
import { useEffect, useRef, useState } from "react";
import { Check, ChevronDown, Plus, Trash2 } from "lucide-react";
import { useStore, formatTime, type Bot } from "@/state/store";
import { useStore, formatTime, type Bot, type Task } from "@/state/store";
import { cn } from "@/lib/cn";
import { formatTokens } from "@/lib/format-tokens";

/** Quiet per-task token tally — input+output combined, because one honest
* total reads faster than a split; the split lives in the hover title. */
function TaskUsage({ usage }: { usage: Task["usage"] }) {
if (!usage) return null;
const label = formatTokens(usage.input + usage.output);
if (!label) return null;
return (
<span title={`${usage.input.toLocaleString()} in · ${usage.output.toLocaleString()} out`}>
{" · "}
{label}
</span>
);
}

export function TaskPicker({ bot }: { bot: Bot }) {
const { dispatch } = useStore();
Expand All @@ -22,6 +37,7 @@ export function TaskPicker({ bot }: { bot: Bot }) {
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
// SAFETY: a mousedown target inside a document is always a DOM Node
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
Expand Down Expand Up @@ -54,11 +70,20 @@ export function TaskPicker({ bot }: { bot: Bot }) {
if (title) dispatch({ type: "renameTask", botId: bot.id, threadId, title });
};

// the picker button stays as-is — a token count next to a truncated title
// and count would crowd it; the open task's tally rides the hover title
const u = current?.usage;
const currentLabel = u ? formatTokens(u.input + u.output) : null;
const switchTitle =
u && currentLabel
? `Switch task · ${currentLabel} (${u.input.toLocaleString()} in · ${u.output.toLocaleString()} out)`
: "Switch task";

return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((o) => !o)}
title="Switch task"
title={switchTitle}
className="flex max-w-[220px] items-center gap-1.5 rounded-full border border-hairline/40 px-2.5 py-1 text-[12.5px] text-ink-secondary hover:bg-raised hover:text-ink"
>
<span className="truncate">{current?.title ?? "Task"}</span>
Expand Down Expand Up @@ -103,7 +128,10 @@ export function TaskPicker({ bot }: { bot: Bot }) {
title="Click to switch · double-click to rename"
>
<div className="truncate text-[13px] text-ink">{task.title}</div>
<div className="text-[11px] text-ink-secondary">{formatTime(task.createdAt)}</div>
<div className="text-[11px] text-ink-secondary">
{formatTime(task.createdAt)}
<TaskUsage usage={task.usage} />
</div>
</button>
)}
<button
Expand Down
37 changes: 37 additions & 0 deletions src/lib/format-tokens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";

import { formatTokens } from "./format-tokens";

describe("formatTokens", () => {
it("hides zero, negatives and non-finite values entirely", () => {
expect(formatTokens(0)).toBeNull();
expect(formatTokens(-5)).toBeNull();
expect(formatTokens(Number.NaN)).toBeNull();
expect(formatTokens(Number.POSITIVE_INFINITY)).toBeNull();
});

it("spells out sub-thousand counts", () => {
expect(formatTokens(1)).toBe("1 token");
expect(formatTokens(2)).toBe("2 tokens");
expect(formatTokens(842)).toBe("842 tokens");
expect(formatTokens(999)).toBe("999 tokens");
});

it("switches to k exactly at 1000, without a trailing .0", () => {
expect(formatTokens(1000)).toBe("1k");
expect(formatTokens(1049)).toBe("1k");
expect(formatTokens(1050)).toBe("1.1k");
expect(formatTokens(12_300)).toBe("12.3k");
expect(formatTokens(12_349)).toBe("12.3k");
expect(formatTokens(12_350)).toBe("12.4k");
});

it("promotes to M exactly where k-rounding would reach 1000k", () => {
expect(formatTokens(999_949)).toBe("999.9k");
expect(formatTokens(999_950)).toBe("1M");
expect(formatTokens(1_000_000)).toBe("1M");
expect(formatTokens(1_240_000)).toBe("1.2M");
expect(formatTokens(1_250_000)).toBe("1.3M");
expect(formatTokens(123_456_789)).toBe("123.5M");
});
});
22 changes: 22 additions & 0 deletions src/lib/format-tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/** Token counts as quiet metadata: "842 tokens", "12.3k", "1.2M".
*
* Null (not "0") for zero so callers can drop the chip entirely — a task
* that never ran shouldn't advertise a tally. Rounding runs on integer
* tenths (n/100, n/100_000) rather than floats: 999_950/1000*10 lands on
* 9999.499… in binary and would round DOWN to "999.9k" instead of
* promoting to "1M". */
export function formatTokens(total: number): string | null {
if (!Number.isFinite(total) || total < 1) return null;
const n = Math.trunc(total);
if (n < 1000) return n === 1 ? "1 token" : `${n} tokens`;
// tenths-of-a-thousand; >= 10000 tenths means the rounded value hit 1000k
const kTenths = Math.round(n / 100);
if (kTenths < 10_000) return `${tenths(kTenths)}k`;
return `${tenths(Math.round(n / 100_000))}M`;
}

/** 123 tenths → "12.3"; whole values drop the ".0" ("1k", not "1.0k"). */
function tenths(t: number): string {
const frac = t % 10;
return frac === 0 ? `${t / 10}` : `${(t - frac) / 10}.${frac}`;
}
3 changes: 3 additions & 0 deletions src/state/store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ export interface Task {
/** folder this task's turns run in, pinned on its first turn; null =
* legacy home-folder session; absent = not pinned yet */
cwd?: string | null;
/** cumulative token spend across this task's settled turns, as the
* server tallies it; absent until the first turn completes */
usage?: { input: number; output: number; turns: number };
}

export interface Bot {
Expand Down
Loading