Skip to content

Commit 1ca196f

Browse files
committed
feat(tui): add footer to choose every message footer detail the same way
Message footers grew one setting per detail, each with its own value shape: turn_timing.time/duration are booleans, timestamps is a type list, footer_variant is a boolean, and the agent, model and total have no setting at all. There was also no way to see a message's ID, which is what logs, the database and the API key everything by. footer in tui.json has one key per detail - agent, model, variant, time, duration, total and the new message_id - and every key takes the same value: true/"all", false/"none", "important", or a list of entry types from the timestamps vocabulary (user, assistant, text, reasoning, tool, error, compaction), which may include "important". "important" selects the landmarks ctrl+shift+up/down stops on, reusing navigationTargets(..., "landmark"); a message-level entry counts when the message or one of its parts is one (isLandmark in navigation.ts). A type a key does not apply to has no effect, which each key's schema description lists. message_id shows the msg_ ID at the end of user and assistant footers as plain text, so it can be selected with the mouse to copy. Time, duration and message_id also give a turn's earlier steps a footer once they finish, as turn_timing did. Unset keys keep today's behavior: turn_timing, footer_variant and timestamps map onto footer.time, footer.duration and footer.variant (footerSelections in config/index.tsx), and /timestamps, /turn-times and /turn-durations persist as before. A key set in footer decides on every start and its toggle lasts until exit, as timestamps already did. Defaults render the upstream footer unchanged. AI-Tool: opencode 1.18.34-vt-163-907b3bc518 AI-Model: anthropic/claude-opus-5-5 AI-Variant: high AI-Platform: linux AI-Harness: Vibeterm 18dc0eb AI-Session-ID: ses_ee68aab01ffei41gwUGJ5ESj6W
1 parent 06eba87 commit 1ca196f

6 files changed

Lines changed: 462 additions & 108 deletions

File tree

‎packages/tui/src/config/index.tsx‎

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ export const TurnTiming = Schema.Struct({
5252
"Show how long each assistant turn took; the final turn also keeps the total since the prompt (default: false)",
5353
}),
5454
}).annotate({
55-
description: "Default per-turn timing in assistant message footers; toggle at runtime from the command palette",
55+
description:
56+
"Legacy default per-turn timing in assistant message footers, used while footer.time and footer.duration are unset; toggle at runtime with /turn-times and /turn-durations",
5657
})
5758

5859
export const SidebarContext = Schema.Literals(["expanded", "compact"]).annotate({
@@ -145,6 +146,7 @@ export const Timestamps = Schema.Union([
145146
"turn_timing.time, and those toggles persist.",
146147
"Set: this list decides every type on each start, overriding turn_timing.time,",
147148
"and /timestamps and /turn-times last until the TUI exits.",
149+
"Legacy: footer.time takes the same list and wins when set; 'tool' here also shows tool call durations.",
148150
].join(" "),
149151
})
150152

@@ -157,6 +159,150 @@ export function timestampTypes(value: Info["timestamps"]): ReadonlySet<Timestamp
157159
return new Set(TimestampType.literals.filter((type) => names.includes(type)))
158160
}
159161

162+
// One value shape for every footer element. The type names are the `timestamps` vocabulary;
163+
// 'important' adds the landmarks that ctrl+shift+up/down stops on.
164+
const footerSelectorNames = [...TimestampType.literals, "important"].join("|")
165+
export const FooterSelector = Schema.Literals([...TimestampType.literals, "important"])
166+
export const FooterValue = Schema.Union([
167+
Schema.Boolean,
168+
Schema.String.check(
169+
Schema.isPattern(
170+
new RegExp(`^\\s*(all|none|(${footerSelectorNames})(\\s*,\\s*(${footerSelectorNames}))*)\\s*$`),
171+
),
172+
),
173+
Schema.Array(FooterSelector),
174+
])
175+
export type FooterValue = Schema.Schema.Type<typeof FooterValue>
176+
177+
const footerValueHelp = [
178+
"true or 'all' - everywhere it applies; false or 'none' - nowhere;",
179+
"'important' - only on landmarks, the entries ctrl+shift+up/down stops on: prompts, each turn's final response,",
180+
"and question, todo and subagent tool calls;",
181+
"or a list of entry types, as a comma-separated string ('user,assistant') or an array, which may include 'important'.",
182+
].join(" ")
183+
const footerElement = (description: string) =>
184+
Schema.optional(FooterValue).annotate({ description: `${description} Value: ${footerValueHelp}` })
185+
186+
export const Footer = Schema.Struct({
187+
agent: footerElement("The agent that ran a turn, in assistant footers. Applies to: assistant. Default: all."),
188+
model: footerElement(
189+
"The model a turn ran on, in assistant footers; model_label picks its name or id. Applies to: assistant. Default: all.",
190+
),
191+
variant: footerElement(
192+
"The variant a turn ran with, such as its reasoning effort ('high'), after the model; turns without a variant show nothing. Applies to: assistant. Default: none, or all with the legacy footer_variant: true.",
193+
),
194+
time: footerElement(
195+
[
196+
"When an entry happened: the time of day if today, the date and time otherwise.",
197+
"Applies to: user (prompt sent), assistant (turn finished, in its footer), text (text block finished, below it),",
198+
"reasoning (thinking block finished), tool (tool call finished), error (turn failed, in its error box),",
199+
"compaction (compaction started, in its divider).",
200+
"Default: the legacy timestamps list when set; otherwise prompts and turns follow /timestamps and /turn-times",
201+
"(turn_timing.time for turns) and the other types are hidden.",
202+
].join(" "),
203+
),
204+
duration: footerElement(
205+
[
206+
"How long an entry took. Applies to: assistant (the turn's own message), reasoning (the thinking block),",
207+
"tool (the tool call).",
208+
"Default: reasoning; turns follow /turn-durations (turn_timing.duration); tool calls when the legacy",
209+
"timestamps list includes 'tool'.",
210+
].join(" "),
211+
),
212+
total: footerElement(
213+
"Time from the prompt to the end of the turn. Applies to: assistant, and only on a turn's final message, so 'important' and 'assistant' select the same footers as 'all'. Default: all.",
214+
),
215+
message_id: footerElement(
216+
"The message ID (msg_...), selectable for drag-to-copy, at the end of the footer. Applies to: user, assistant. Default: none.",
217+
),
218+
}).annotate({
219+
description: [
220+
"Which details message footers show. Every element takes the same value.",
221+
"Assistant footers always show on a turn's last message; time, duration and message_id also show them on",
222+
"the turn's earlier messages they select.",
223+
"An element set here decides on every start, and its runtime toggle (/timestamps, /turn-times,",
224+
"/turn-durations) lasts until the TUI exits; unset, those toggles persist.",
225+
"Selecting a type an element does not apply to has no effect.",
226+
].join(" "),
227+
})
228+
export type Footer = Schema.Schema.Type<typeof Footer>
229+
230+
export type FooterElement = keyof Footer
231+
export type FooterSelection = { important: boolean; types: ReadonlySet<TimestampType> }
232+
233+
const noFooterTypes: ReadonlySet<TimestampType> = new Set()
234+
const allFooterTypes: ReadonlySet<TimestampType> = new Set(TimestampType.literals)
235+
236+
export function footerSelection(value: FooterValue): FooterSelection {
237+
if (value === true) return { important: false, types: allFooterTypes }
238+
if (value === false) return { important: false, types: noFooterTypes }
239+
const names: readonly string[] = typeof value === "string" ? value.split(",").map((name) => name.trim()) : value
240+
if (names.includes("all")) return { important: false, types: allFooterTypes }
241+
return {
242+
important: names.includes("important"),
243+
types: new Set(TimestampType.literals.filter((type) => names.includes(type))),
244+
}
245+
}
246+
247+
// Whether a selection shows on any entry of `type`, which is what a runtime toggle reports.
248+
export function footerSelects(selection: FooterSelection, type: TimestampType) {
249+
return selection.important || selection.types.has(type)
250+
}
251+
252+
// Whether a selection shows on one entry; `landmark` is only asked when 'important' decides.
253+
export function footerShows(selection: FooterSelection, type: TimestampType, landmark: () => boolean) {
254+
if (selection.types.has(type)) return true
255+
return selection.important && landmark()
256+
}
257+
258+
// Whether the runtime toggles for an element are session-only because tui.json decides it.
259+
export function footerConfigured(config: Pick<Info, "footer" | "timestamps">, element: FooterElement) {
260+
if (config.footer?.[element] !== undefined) return true
261+
return element === "time" && config.timestamps !== undefined
262+
}
263+
264+
// What each footer element selects before runtime overrides. `footer` wins; unset elements keep
265+
// the behavior of the legacy keys (timestamps, turn_timing, footer_variant) and of the persisted
266+
// /timestamps, /turn-times and /turn-durations toggles passed in `saved`.
267+
export function footerSelections(
268+
config: Pick<Info, "footer" | "timestamps" | "turn_timing" | "footer_variant">,
269+
saved: { timestamps?: boolean; turnTime?: boolean; turnDuration?: boolean },
270+
): Record<FooterElement, FooterSelection> {
271+
const legacy = timestampTypes(config.timestamps)
272+
const pick = (types: (TimestampType | false)[]) => ({
273+
important: false,
274+
types: new Set(TimestampType.literals.filter((type) => types.includes(type))),
275+
})
276+
const element = (key: FooterElement, fallback: FooterSelection) => {
277+
const value = config.footer?.[key]
278+
return value === undefined ? fallback : footerSelection(value)
279+
}
280+
return {
281+
agent: element("agent", footerSelection(true)),
282+
model: element("model", footerSelection(true)),
283+
variant: element("variant", footerSelection(config.footer_variant ?? false)),
284+
time: element(
285+
"time",
286+
legacy
287+
? { important: false, types: legacy }
288+
: pick([
289+
saved.timestamps === true && "user",
290+
(saved.turnTime ?? config.turn_timing?.time) === true && "assistant",
291+
]),
292+
),
293+
duration: element(
294+
"duration",
295+
pick([
296+
"reasoning",
297+
(saved.turnDuration ?? config.turn_timing?.duration) === true && "assistant",
298+
legacy?.has("tool") === true && "tool",
299+
]),
300+
),
301+
total: element("total", footerSelection(true)),
302+
message_id: element("message_id", footerSelection(false)),
303+
}
304+
}
305+
160306
export const TranscriptMaxMessagesDefault = 100
161307
export const Transcript = Schema.Struct({
162308
max_messages: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))).annotate({
@@ -220,8 +366,9 @@ export const Info = Schema.Struct({
220366
timestamps: Schema.optional(Timestamps),
221367
footer_variant: Schema.optional(Schema.Boolean).annotate({
222368
description:
223-
"Show the model variant a turn ran with, such as its reasoning effort ('high'), after the model in assistant message footers; turns without a variant are unchanged (default: false)",
369+
"Legacy alias of footer.variant: true shows the variant a turn ran with, such as its reasoning effort ('high'), after the model in assistant message footers (default: false). footer.variant wins when set",
224370
}),
371+
footer: Schema.optional(Footer),
225372
cursor: Schema.optional(Cursor),
226373
sidebar: Schema.optional(Sidebar),
227374
usage: Schema.optional(Usage),

0 commit comments

Comments
 (0)