Skip to content

Commit 1b4386f

Browse files
See USee U
authored andcommitted
feat(tui): share usage context formatting and refine thinking state
1 parent 6dcf8f3 commit 1b4386f

6 files changed

Lines changed: 94 additions & 22 deletions

File tree

packages/tui/src/component/prompt/index.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import { type AutocompleteRef, Autocomplete } from "./autocomplete"
3838
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
3939
import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
4040
import { Locale } from "../../util/locale"
41-
import { usageColor } from "../../util/usage"
41+
import { usageColor, usageContext } from "../../util/usage"
4242
import { GLYPH } from "../../ui/glyphs"
4343
import { ACTIVITY_VERBS, activityVerb } from "../../ui/activity-verbs"
4444
import { errorMessage } from "../../util/error"
@@ -240,16 +240,14 @@ export function Prompt(props: PromptProps) {
240240
const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
241241
if (!last) return
242242

243-
const tokens =
244-
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
245-
if (tokens <= 0) return
246-
247243
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
248-
const pct = model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : undefined
244+
const base = usageContext(last.tokens, model?.limit.context)
245+
if (!base) return
246+
249247
const cost = session?.cost ?? 0
250248
return {
251-
context: pct !== undefined ? `${Locale.number(tokens)} (${pct}%)` : Locale.number(tokens),
252-
percent: pct,
249+
context: base.context,
250+
percent: base.percent,
253251
cost: cost > 0 ? Locale.money(cost) : undefined,
254252
}
255253
})

packages/tui/src/context/thinking.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,31 @@ const MODES: readonly ThinkingMode[] = ["show", "hide"] as const
1212
export function reasoningSummary(text: string) {
1313
const content = text.trim()
1414
const match = content.match(/^\*\*([^*\n]+)\*\*(?:\r?\n\r?\n|$)/)
15-
if (!match) return { title: null, body: content }
16-
return { title: match[1].trim(), body: content.slice(match[0].length).trimEnd() }
15+
if (match) return { title: match[1].trim(), body: content.slice(match[0].length).trimEnd() }
16+
return { title: proseTitle(content), body: content }
17+
}
18+
19+
const TITLE_MAX = 60
20+
21+
// DeepSeek and most other providers stream plain-prose reasoning (no title
22+
// block); surface a topic preview from the first line so collapsed thinking
23+
// still hints at what the model is doing. Markdown decorations are stripped,
24+
// and the preview truncates at a word boundary.
25+
function proseTitle(text: string): string | null {
26+
const firstLine = text.split(/\r?\n/, 1)[0].trim()
27+
if (!firstLine) return null
28+
const stripped = firstLine
29+
.replace(/^#{1,6}\s+/, "")
30+
.replace(/^[-*>+]\s+/, "")
31+
.replace(/^\d+[.)]\s+/, "")
32+
.replace(/\*\*/g, "")
33+
.replace(/`/g, "")
34+
.trim()
35+
if (!stripped) return null
36+
if (stripped.length <= TITLE_MAX) return stripped
37+
const cut = stripped.slice(0, TITLE_MAX)
38+
const lastSpace = cut.lastIndexOf(" ")
39+
return `${(lastSpace > TITLE_MAX / 2 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`
1740
}
1841

1942
export function isThinkingMode(value: unknown): value is ThinkingMode {

packages/tui/src/routes/session/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1596,6 +1596,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
15961596
internalBlockMode="top-level"
15971597
content={ctx.conceal() ? polishMarkdown(summary().body) : summary().body}
15981598
conceal={ctx.conceal()}
1599+
streaming={!isDone()}
15991600
fg={theme.textMuted}
16001601
bg={theme.background}
16011602
/>

packages/tui/src/routes/session/subagent-footer.tsx

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { useLocal } from "../../context/local"
66
import { space } from "../../design-tokens"
77
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
88
import { Locale } from "../../util/locale"
9-
import { usageColor } from "../../util/usage"
9+
import { usageColor, usageContext } from "../../util/usage"
1010
import { useCommandShortcut, useOpencodeKeymap } from "../../keymap"
1111
import { PixelIcon } from "../../component/icon-renderable"
1212
import { statusInfo } from "../../ui/icon"
@@ -40,17 +40,14 @@ export function SubagentFooter() {
4040
const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
4141
if (!last) return
4242

43-
const tokens =
44-
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
45-
if (tokens <= 0) return
46-
4743
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
48-
const pct = model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : undefined
49-
const cost = session()?.cost ?? 0
44+
const base = usageContext(last.tokens, model?.limit.context)
45+
if (!base) return
5046

47+
const cost = session()?.cost ?? 0
5148
return {
52-
context: pct !== undefined ? `${Locale.number(tokens)} (${pct}%)` : Locale.number(tokens),
53-
percent: pct,
49+
context: base.context,
50+
percent: base.percent,
5451
cost: cost > 0 ? Locale.money(cost) : undefined,
5552
}
5653
})

packages/tui/src/util/usage.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Locale } from "./locale"
12
import type { Theme } from "../theme"
23

34
/**
@@ -9,3 +10,24 @@ export function usageColor(theme: Theme, percent: number | undefined) {
910
if (percent > 60) return theme.warning
1011
return theme.textMuted
1112
}
13+
14+
/**
15+
* Shared "tokens (pct) · cache N" usage string for the prompt bar and the
16+
* subagent footer. Returns undefined when there is no token usage to show.
17+
*/
18+
export function usageContext(
19+
tokens: {
20+
readonly input: number
21+
readonly output: number
22+
readonly reasoning: number
23+
readonly cache: { readonly read: number; readonly write: number }
24+
},
25+
contextLimit: number | undefined,
26+
) {
27+
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
28+
if (total <= 0) return undefined
29+
const percent = contextLimit ? Math.round((total / contextLimit) * 100) : undefined
30+
const context = percent !== undefined ? `${Locale.number(total)} (${percent}%)` : Locale.number(total)
31+
const cache = tokens.cache.read > 0 ? ` · cache ${Locale.number(tokens.cache.read)}` : ""
32+
return { context: `${context}${cache}`, percent }
33+
}

packages/tui/test/cli/tui/thinking.test.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,45 @@ describe("reasoningSummary", () => {
2323
})
2424
})
2525

26-
test("does not consume ordinary leading bold content", () => {
26+
test("previews ordinary leading bold content as a prose title", () => {
2727
expect(reasoningSummary("**Important:** keep this in the body.")).toEqual({
28-
title: null,
28+
title: "Important: keep this in the body.",
2929
body: "**Important:** keep this in the body.",
3030
})
3131
})
3232

3333
test("leaves content without a leading title in its body", () => {
34-
expect(reasoningSummary("Details only.")).toEqual({ title: null, body: "Details only." })
34+
expect(reasoningSummary("Details only.")).toEqual({ title: "Details only.", body: "Details only." })
35+
})
36+
37+
test("previews DeepSeek-style prose reasoning from its first line", () => {
38+
expect(
39+
reasoningSummary(
40+
"Let me trace how the session runner assembles the request.\n\nA few things to verify: the tool schema order, the system prompt, and the reasoning echo.",
41+
),
42+
).toEqual({
43+
title: "Let me trace how the session runner assembles the request.",
44+
body: "Let me trace how the session runner assembles the request.\n\nA few things to verify: the tool schema order, the system prompt, and the reasoning echo.",
45+
})
46+
})
47+
48+
test("strips markdown decorations from the prose preview", () => {
49+
expect(reasoningSummary("## Approach\nFirst, check the cache fields.\n\nThen verify.")).toEqual({
50+
title: "Approach",
51+
body: "## Approach\nFirst, check the cache fields.\n\nThen verify.",
52+
})
53+
expect(reasoningSummary("- Investigate the failing test\n- Fix it")).toEqual({
54+
title: "Investigate the failing test",
55+
body: "- Investigate the failing test\n- Fix it",
56+
})
57+
})
58+
59+
test("truncates long prose previews at a word boundary", () => {
60+
const longLine =
61+
"Analyzing the interplay between the streamed usage payload and the cache accounting pipeline. ".repeat(2)
62+
const result = reasoningSummary(longLine)
63+
expect(result.title?.length).toBeLessThanOrEqual(61)
64+
expect(result.title?.endsWith("…")).toBe(true)
65+
expect(result.title).toBe("Analyzing the interplay between the streamed usage payload…")
3566
})
3667
})

0 commit comments

Comments
 (0)