Compare commits

...

3 Commits

Author SHA1 Message Date
Dax Raad
184b6d0117 tui: show token generation speed (tok/s) in assistant messages
Users can now see how fast the model generates tokens alongside the total response time, making it easier to compare model performance and identify slower responses.
2026-02-20 23:49:32 -05:00
Adam
6d58d899f7 fix: e2e test outdated 2026-02-20 19:44:06 -06:00
Adam
b75a27d43e chore: cleanup 2026-02-20 19:37:35 -06:00
7 changed files with 72 additions and 21 deletions

View File

@@ -20,11 +20,8 @@ export const settingsNotificationsAgentSelector = '[data-action="settings-notifi
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
export const settingsSoundsAgentEnabledSelector = '[data-action="settings-sounds-agent-enabled"]'
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
export const settingsSoundsPermissionsEnabledSelector = '[data-action="settings-sounds-permissions-enabled"]'
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
export const settingsSoundsErrorsEnabledSelector = '[data-action="settings-sounds-errors-enabled"]'
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'

View File

@@ -9,7 +9,6 @@ import {
settingsNotificationsPermissionsSelector,
settingsReleaseNotesSelector,
settingsSoundsAgentSelector,
settingsSoundsAgentEnabledSelector,
settingsSoundsErrorsSelector,
settingsSoundsPermissionsSelector,
settingsThemeSelector,
@@ -336,21 +335,19 @@ test("changing sound agent selection persists in localStorage", async ({ page, g
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
})
test("disabling agent sound disables sound selection", async ({ page, gotoSession }) => {
test("selecting none disables agent sound", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsSoundsAgentSelector)
const switchContainer = dialog.locator(settingsSoundsAgentEnabledSelector)
const trigger = select.locator('[data-slot="select-select-trigger"]')
await expect(select).toBeVisible()
await expect(switchContainer).toBeVisible()
await expect(trigger).toBeEnabled()
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
await expect(trigger).toBeDisabled()
await trigger.click()
const items = page.locator('[data-slot="select-select-item"]')
await expect(items.first()).toBeVisible()
await items.first().click()
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)

View File

@@ -1101,6 +1101,7 @@ export function Session() {
</Match>
<Match when={message.role === "assistant"}>
<AssistantMessage
index={index()}
last={lastAssistant()?.id === message.id}
message={message as AssistantMessage}
parts={sync.data.part[message.id] ?? []}
@@ -1269,7 +1270,7 @@ function UserMessage(props: {
)
}
function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
function AssistantMessage(props: { index: number; message: AssistantMessage; parts: Part[]; last: boolean }) {
const local = useLocal()
const { theme } = useTheme()
const sync = useSync()
@@ -1279,12 +1280,32 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
})
const duration = createMemo(() => {
if (!final()) return 0
if (!props.message.time.completed) return 0
const user = messages().find((x) => x.role === "user" && x.id === props.message.parentID)
if (!user || !user.time) return 0
return props.message.time.completed - user.time.created
const stats = createMemo(() => {
if (!final() || !props.message.time.completed) return null
const list = messages()
let tokens = 0
let active = 0
for (let i = props.index; i >= 0; i--) {
const msg = list[i]
if (msg.role === "assistant") {
tokens += msg.tokens?.output || 0
if (msg.time.started && msg.time.streamed) {
const delta = msg.time.streamed - msg.time.started
if (delta > 0) active += delta
}
}
if (msg.role === "user" && msg.id === props.message.parentID) {
if (!msg.time?.created) return null
const total = props.message.time.completed - msg.time.created
if (total <= 0 || active <= 0) return null
const tps = tokens > 0 ? tokens / (active / 1000) : null
return { total, tps }
}
}
return null
})
return (
@@ -1334,8 +1355,14 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
</span>{" "}
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
<span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
<Show when={duration()}>
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
<Show when={stats()}>
{(s) => (
<span style={{ fg: theme.textMuted }}>
{" "}
· {Locale.duration(s().total)}
<Show when={s().tps !== null}> · {s().tps!.toFixed(1)} tok/s</Show>
</span>
)}
</Show>
<Show when={props.message.error?.name === "MessageAbortedError"}>
<span style={{ fg: theme.textMuted }}> · interrupted</span>

View File

@@ -392,7 +392,9 @@ export namespace MessageV2 {
role: z.literal("assistant"),
time: z.object({
created: z.number(),
started: z.number().optional(),
completed: z.number().optional(),
streamed: z.number().optional(),
}),
error: z
.discriminatedUnion("name", [

View File

@@ -137,7 +137,9 @@ export namespace Message {
.object({
time: z.object({
created: z.number(),
started: z.number().optional(),
completed: z.number().optional(),
streamed: z.number().optional(),
}),
error: z
.discriminatedUnion("name", [AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema])

View File

@@ -57,6 +57,10 @@ export namespace SessionProcessor {
switch (value.type) {
case "start":
SessionStatus.set(input.sessionID, { type: "busy" })
if (!input.assistantMessage.time.started) {
input.assistantMessage.time.started = Date.now()
await Session.updateMessage(input.assistantMessage)
}
break
case "reasoning-start":
@@ -337,6 +341,10 @@ export namespace SessionProcessor {
break
case "finish":
if (!input.assistantMessage.time.streamed) {
input.assistantMessage.time.streamed = Date.now()
await Session.updateMessage(input.assistantMessage)
}
break
default:

View File

@@ -490,8 +490,10 @@
}
[data-component="edit-content"] {
border-radius: inherit;
border-top: 1px solid var(--border-weaker-base);
max-height: 420px;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: none;
@@ -500,15 +502,24 @@
&::-webkit-scrollbar {
display: none;
}
[data-component="diff"] {
border-radius: inherit;
overflow: hidden;
}
}
[data-component="write-content"] {
border-radius: inherit;
border-top: 1px solid var(--border-weaker-base);
max-height: 240px;
overflow-x: hidden;
overflow-y: auto;
[data-component="code"] {
padding-bottom: 0px !important;
padding-bottom: 0 !important;
border-radius: inherit;
overflow: hidden;
}
/* Hide scrollbar */
@@ -1285,6 +1296,8 @@
}
[data-component="apply-patch-file-diff"] {
border-radius: inherit;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
@@ -1292,6 +1305,11 @@
&::-webkit-scrollbar {
display: none;
}
[data-component="diff"] {
border-radius: inherit;
overflow: hidden;
}
}
[data-component="tool-loaded-file"] {