From 41c44cba167f159a5a89b109f7d38b824dff5ea5 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Wed, 5 Aug 2026 13:59:44 -0400 Subject: [PATCH 1/3] feat(simulate): render summary citations as links to the cited turn The summarization model cites the conversation turns behind a finding with quoted text. In the TUI those become OSC 8 hyperlinks to the item in the dashboard; in reports and CI logs, which cannot carry a link, the tag is reduced to the quoted text. --- cmd/lk/simulate.go | 10 ++++ cmd/lk/simulate_refs.go | 91 ++++++++++++++++++++++++++++++++++++ cmd/lk/simulate_refs_test.go | 59 +++++++++++++++++++++++ cmd/lk/simulate_report.go | 8 ++-- cmd/lk/simulate_tui.go | 11 +++-- 5 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 cmd/lk/simulate_refs.go create mode 100644 cmd/lk/simulate_refs_test.go diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index e45278cec..0588e4c1a 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -655,6 +655,16 @@ func simulationJobDashboardURL(projectID, runID, jobID string) string { return fmt.Sprintf("%s?job=%s", base, jobID) } +// simulationItemDashboardURL points at a single chat item within a job, the +// target of a citation in the run summary. +func simulationItemDashboardURL(projectID, runID, jobID, itemID string) string { + base := simulationJobDashboardURL(projectID, runID, jobID) + if base == "" || itemID == "" { + return base + } + return fmt.Sprintf("%s&item=%s", base, itemID) +} + func cancelSimulationRun(client *lksdk.AgentSimulationClient, runID string) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/cmd/lk/simulate_refs.go b/cmd/lk/simulate_refs.go new file mode 100644 index 000000000..1ba9c90be --- /dev/null +++ b/cmd/lk/simulate_refs.go @@ -0,0 +1,91 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "github.com/charmbracelet/lipgloss" + + "github.com/livekit/livekit-cli/v2/pkg/util" +) + +// The summarization model cites the conversation turns behind a finding with +// quoted text. Attribute order is not +// guaranteed, so the tag is matched loosely and the attributes are extracted +// separately. +var ( + summaryRefPattern = regexp.MustCompile(`(?s)]*)>(.*?)`) + summaryRefAttrPattern = regexp.MustCompile(`([a-zA-Z]+)\s*=\s*"([^"]*)"`) +) + +// summaryRefStyle marks cited text as a link, for terminals that render OSC 8 +// hyperlinks no differently from surrounding text. +func summaryRefStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(util.Brand()).Underline(true) +} + +// linkSummaryRefs replaces each in summary prose with its quoted text as +// a clickable link to the cited chat item. A ref missing a job, or a run with +// no dashboard URL, degrades to the quoted text alone. +func linkSummaryRefs(text, projectID, runID string) string { + return replaceSummaryRefs(text, func(attrs map[string]string, label string) string { + url := simulationItemDashboardURL(projectID, runID, attrs["job"], attrs["item"]) + if url == "" { + return label + } + return util.Hyperlink(url, summaryRefStyle().Render(label)) + }) +} + +// stripSummaryRefs reduces each in summary prose to its quoted text, for +// output that cannot carry a link (files, CI logs, redirected stdout). +func stripSummaryRefs(text string) string { + return replaceSummaryRefs(text, func(_ map[string]string, label string) string { + return label + }) +} + +// replaceSummaryRefs rewrites every in text through render. Citations are +// often appended to a sentence with no separator, either directly after the +// full stop or back-to-back with each other, so a ref that abuts the text +// before it gains a leading space; without one the quotes run together into a +// single unreadable phrase. +func replaceSummaryRefs(text string, render func(attrs map[string]string, label string) string) string { + var b strings.Builder + end := 0 + for _, m := range summaryRefPattern.FindAllStringSubmatchIndex(text, -1) { + b.WriteString(text[end:m[0]]) + if m[0] > 0 && !endsWithSpace(text[:m[0]]) { + b.WriteString(" ") + } + attrs := make(map[string]string) + for _, attr := range summaryRefAttrPattern.FindAllStringSubmatch(text[m[2]:m[3]], -1) { + attrs[strings.ToLower(attr[1])] = attr[2] + } + b.WriteString(render(attrs, text[m[4]:m[5]])) + end = m[1] + } + b.WriteString(text[end:]) + return b.String() +} + +func endsWithSpace(s string) bool { + r, _ := utf8.DecodeLastRuneInString(s) + return unicode.IsSpace(r) +} diff --git a/cmd/lk/simulate_refs_test.go b/cmd/lk/simulate_refs_test.go new file mode 100644 index 000000000..e393264e5 --- /dev/null +++ b/cmd/lk/simulate_refs_test.go @@ -0,0 +1,59 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const refProse = `it kept asking for details in "I've had a few, sure" and "I'm totally fine to drive".` + +func TestStripSummaryRefs(t *testing.T) { + require.Equal(t, + `it kept asking for details in "I've had a few, sure" and "I'm totally fine to drive".`, + stripSummaryRefs(refProse), + ) + + // footnote-style citations: appended to a sentence and to each other + require.Equal(t, + "left out the passport requirement. accepted cards exchange rate posting", + stripSummaryRefs(`left out the passport requirement.accepted cardsexchange rate posting`), + ) + + // prose without refs, and a ref spanning a newline + require.Equal(t, "nothing to strip", stripSummaryRefs("nothing to strip")) + require.Equal(t, "a\nquote", stripSummaryRefs("a\nquote")) +} + +func TestLinkSummaryRefs(t *testing.T) { + linked := linkSummaryRefs(refProse, "proj", "run") + + require.NotContains(t, linked, "") + // both refs link to their own item, whatever the attribute order + require.Contains(t, linked, "runs/run?job=SRJ_Bzb9ZaoJFJyp&item=item_dd0ee81187bd") + require.Contains(t, linked, "runs/run?job=SRJ_Bzb9ZaoJFJyp&item=item_13b90227fe38") + require.Contains(t, linked, `"I've had a few, sure"`) + require.Equal(t, 2, strings.Count(linked, "\x1b]8;;"+dashboardBaseURL())) +} + +func TestLinkSummaryRefsWithoutTarget(t *testing.T) { + // no project or run to link to, and a ref with no job: quoted text only + require.Equal(t, stripSummaryRefs(refProse), linkSummaryRefs(refProse, "", "")) + require.Equal(t, "quoted", linkSummaryRefs(`quoted`, "proj", "run")) +} diff --git a/cmd/lk/simulate_report.go b/cmd/lk/simulate_report.go index c92d454ac..5344ab587 100644 --- a/cmd/lk/simulate_report.go +++ b/cmd/lk/simulate_report.go @@ -255,7 +255,7 @@ func writeRunSummary(w io.Writer, run *livekit.SimulationRun, summary *livekit.S if summary.GoingWell != "" { fmt.Fprintln(w) fmt.Fprintln(w, "Going well:") - for line := range strings.SplitSeq(summary.GoingWell, "\n") { + for line := range strings.SplitSeq(stripSummaryRefs(summary.GoingWell), "\n") { fmt.Fprintf(w, " %s\n", line) } } @@ -263,7 +263,7 @@ func writeRunSummary(w io.Writer, run *livekit.SimulationRun, summary *livekit.S if summary.ToImprove != "" { fmt.Fprintln(w) fmt.Fprintln(w, "To improve:") - for line := range strings.SplitSeq(summary.ToImprove, "\n") { + for line := range strings.SplitSeq(stripSummaryRefs(summary.ToImprove), "\n") { fmt.Fprintf(w, " %s\n", line) } } @@ -272,9 +272,9 @@ func writeRunSummary(w io.Writer, run *livekit.SimulationRun, summary *livekit.S fmt.Fprintln(w) fmt.Fprintln(w, "Issues:") for i, issue := range summary.Issues { - fmt.Fprintf(w, " %d. %s\n", i+1, issue.Description) + fmt.Fprintf(w, " %d. %s\n", i+1, stripSummaryRefs(issue.Description)) if issue.Suggestion != "" { - fmt.Fprintf(w, " Suggestion: %s\n", issue.Suggestion) + fmt.Fprintf(w, " Suggestion: %s\n", stripSummaryRefs(issue.Suggestion)) } } } diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index d7c57217d..eed83843b 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -1760,11 +1760,14 @@ func (m *simulateModel) renderSummary() string { ) wrapWidth := proseWidth(m.width, 6) + link := func(text string) string { + return linkSummaryRefs(text, m.projectID(), m.runID) + } if summary.GoingWell != "" { b.WriteString(greenStyle().Bold(true).Render(" Going well:")) b.WriteString("\n") - wrapped := lipgloss.NewStyle().Width(wrapWidth).Render(summary.GoingWell) + wrapped := lipgloss.NewStyle().Width(wrapWidth).Render(link(summary.GoingWell)) for line := range strings.SplitSeq(wrapped, "\n") { b.WriteString(" " + line + "\n") } @@ -1774,7 +1777,7 @@ func (m *simulateModel) renderSummary() string { if summary.ToImprove != "" { b.WriteString(yellowStyle().Bold(true).Render(" To improve:")) b.WriteString("\n") - wrapped := lipgloss.NewStyle().Width(wrapWidth).Render(summary.ToImprove) + wrapped := lipgloss.NewStyle().Width(wrapWidth).Render(link(summary.ToImprove)) for line := range strings.SplitSeq(wrapped, "\n") { b.WriteString(" " + line + "\n") } @@ -1790,7 +1793,7 @@ func (m *simulateModel) renderSummary() string { } for i, issue := range summary.Issues { prefix := fmt.Sprintf(" %d. ", i+1) - descWrapped := lipgloss.NewStyle().Width(issueWrap).Render(issue.Description) + descWrapped := lipgloss.NewStyle().Width(issueWrap).Render(link(issue.Description)) for j, line := range strings.Split(descWrapped, "\n") { if j == 0 { b.WriteString(prefix + line + "\n") @@ -1799,7 +1802,7 @@ func (m *simulateModel) renderSummary() string { } } if issue.Suggestion != "" { - sugWrapped := lipgloss.NewStyle().Width(issueWrap).Render("Suggestion: " + issue.Suggestion) + sugWrapped := lipgloss.NewStyle().Width(issueWrap).Render("Suggestion: " + link(issue.Suggestion)) for line := range strings.SplitSeq(sugWrapped, "\n") { b.WriteString(dimStyle.Render(strings.Repeat(" ", len(prefix))+line) + "\n") } From b131245400e2c15d2d3fc224a8591edf6020f59f Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 14 Aug 2026 09:48:36 -0400 Subject: [PATCH 2/3] feat(simulate): open a cited turn from the summary with its number A summary citation could only be followed out of the terminal, by ctrl+clicking its OSC 8 link into the dashboard. The turn it cites is already in the run, so the TUI can open it directly. Each citation now carries a number, and pressing that digit on the list view opens the cited job. Numbering follows render order through one index shared by every block of the summary, so the digit a reader presses selects the citation whose label shows it. Only the first nine are numbered: a number is an invitation to press that digit, and there is no tenth digit. A jump lands at the top of the printed job, which scrollback cannot be scrolled past, so the cited turn is marked where it prints. The mark rides the message text rather than the speaker header, which a message continuing an open agent block never prints. Numbering keys off a citation naming a job, not off a dashboard URL resolving: the jump is local, so it works where the link does not. --- cmd/lk/simulate_refs.go | 50 +++++++++++++++++++++++++++++++----- cmd/lk/simulate_refs_test.go | 40 ++++++++++++++++++++++++++--- cmd/lk/simulate_tui.go | 49 ++++++++++++++++++++++++++++++++--- 3 files changed, 125 insertions(+), 14 deletions(-) diff --git a/cmd/lk/simulate_refs.go b/cmd/lk/simulate_refs.go index 1ba9c90be..3eba180ad 100644 --- a/cmd/lk/simulate_refs.go +++ b/cmd/lk/simulate_refs.go @@ -15,6 +15,7 @@ package main import ( + "fmt" "regexp" "strings" "unicode" @@ -40,16 +41,51 @@ func summaryRefStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(util.Brand()).Underline(true) } -// linkSummaryRefs replaces each in summary prose with its quoted text as -// a clickable link to the cited chat item. A ref missing a job, or a run with -// no dashboard URL, degrades to the quoted text alone. -func linkSummaryRefs(text, projectID, runID string) string { +// A citation's number is an invitation to press that digit, so only as many +// citations as there are digits to press carry one. +const maxNumberedSummaryRefs = 9 + +// summaryRefTarget is the chat item a numbered citation points at. +type summaryRefTarget struct { + job string + item string +} + +// summaryRefIndex numbers citations as they are rendered. The number a reader +// sees has to select the same citation when pressed, so one index is threaded +// through every block of a summary and numbering follows render order. +type summaryRefIndex struct { + targets []summaryRefTarget +} + +// add records a citation and returns its 1-based number, or false once every +// digit is spoken for. +func (x *summaryRefIndex) add(attrs map[string]string) (int, bool) { + if len(x.targets) >= maxNumberedSummaryRefs { + return 0, false + } + x.targets = append(x.targets, summaryRefTarget{job: attrs["job"], item: attrs["item"]}) + return len(x.targets), true +} + +// linkSummaryRefs replaces each in summary prose with its quoted text, +// numbered so the digit keys can open the cited turn, and hyperlinked to the +// cited item when the run has a dashboard URL. A ref naming no job cites +// nothing openable and degrades to the quoted text alone. +func linkSummaryRefs(text, projectID, runID string, refs *summaryRefIndex) string { return replaceSummaryRefs(text, func(attrs map[string]string, label string) string { - url := simulationItemDashboardURL(projectID, runID, attrs["job"], attrs["item"]) - if url == "" { + if attrs["job"] == "" { return label } - return util.Hyperlink(url, summaryRefStyle().Render(label)) + n, ok := refs.add(attrs) + if !ok { + return label + } + rendered := summaryRefStyle().Render(label) + if url := simulationItemDashboardURL(projectID, runID, attrs["job"], attrs["item"]); url != "" { + rendered = util.Hyperlink(url, rendered) + } + return rendered + dimStyle.Render(fmt.Sprintf(" [%d]", n)) }) } diff --git a/cmd/lk/simulate_refs_test.go b/cmd/lk/simulate_refs_test.go index e393264e5..a07058c71 100644 --- a/cmd/lk/simulate_refs_test.go +++ b/cmd/lk/simulate_refs_test.go @@ -41,7 +41,8 @@ func TestStripSummaryRefs(t *testing.T) { } func TestLinkSummaryRefs(t *testing.T) { - linked := linkSummaryRefs(refProse, "proj", "run") + var refs summaryRefIndex + linked := linkSummaryRefs(refProse, "proj", "run", &refs) require.NotContains(t, linked, "") @@ -50,10 +51,41 @@ func TestLinkSummaryRefs(t *testing.T) { require.Contains(t, linked, "runs/run?job=SRJ_Bzb9ZaoJFJyp&item=item_13b90227fe38") require.Contains(t, linked, `"I've had a few, sure"`) require.Equal(t, 2, strings.Count(linked, "\x1b]8;;"+dashboardBaseURL())) + + // the number a label carries selects the citation recorded under it + require.Contains(t, linked, "[1]") + require.Contains(t, linked, "[2]") + require.Equal(t, []summaryRefTarget{ + {job: "SRJ_Bzb9ZaoJFJyp", item: "item_dd0ee81187bd"}, + {job: "SRJ_Bzb9ZaoJFJyp", item: "item_13b90227fe38"}, + }, refs.targets) } func TestLinkSummaryRefsWithoutTarget(t *testing.T) { - // no project or run to link to, and a ref with no job: quoted text only - require.Equal(t, stripSummaryRefs(refProse), linkSummaryRefs(refProse, "", "")) - require.Equal(t, "quoted", linkSummaryRefs(`quoted`, "proj", "run")) + // a ref naming no job cites nothing that can be opened: quoted text alone + var unopenable summaryRefIndex + require.Equal(t, "quoted", linkSummaryRefs(`quoted`, "proj", "run", &unopenable)) + require.Empty(t, unopenable.targets) + + // with no dashboard URL the job is still openable from the TUI, so the + // citation keeps its number and loses only the hyperlink + var local summaryRefIndex + linked := linkSummaryRefs(refProse, "", "", &local) + require.NotContains(t, linked, "\x1b]8;;") + require.Contains(t, linked, "[1]") + require.Len(t, local.targets, 2) +} + +func TestSummaryRefIndexStopsAtTheLastDigit(t *testing.T) { + var b strings.Builder + for range maxNumberedSummaryRefs + 2 { + b.WriteString(`q`) + } + + var refs summaryRefIndex + linked := linkSummaryRefs(b.String(), "proj", "run", &refs) + + require.Len(t, refs.targets, maxNumberedSummaryRefs) + require.Contains(t, linked, "[9]") + require.NotContains(t, linked, "[10]") } diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index eed83843b..d5ac2b5ee 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -217,6 +217,12 @@ type simulateModel struct { cursor int detailJobID string + // The summary's citations, in the order their numbers were rendered, so a + // digit key resolves to the turn its label points at. refItemID is the chat + // item a jump cited, marked when the job view prints because printed + // scrollback cannot be scrolled to it. + summaryRefs []summaryRefTarget + refItemID string // The open job's view is printed into the terminal's own scrollback instead // of being windowed in the live region; detailPrinted is what has already // been emitted for it, so a re-render only ever appends its new tail. @@ -953,6 +959,16 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.viewScrollOff += pageScroll // clamped on render } } + // A citation's number opens the turn it cites. Only live on the list view, + // which is where the numbered summary is on screen to read them off. + case "1", "2", "3", "4", "5", "6", "7", "8", "9": + if m.detailJobID == "" { + if ref, ok := m.summaryRef(key); ok { + m.detailJobID = ref.job + m.refItemID = ref.item + return m, m.openDetailCmd() + } + } // j and l sit either side of k on the home row, so they double for the // left/right arrows without reaching for them. case "enter", "right", "l": @@ -1673,9 +1689,19 @@ func (m *simulateModel) openDetailCmd() tea.Cmd { func (m *simulateModel) closeDetailCmd() tea.Cmd { m.detailJobID = "" m.detailPrinted = "" + m.refItemID = "" return tea.EnterAltScreen } +// summaryRef resolves a digit key to the citation whose label carries it. +func (m *simulateModel) summaryRef(key string) (summaryRefTarget, bool) { + n := int(key[0] - '0') + if n < 1 || n > len(m.summaryRefs) { + return summaryRefTarget{}, false + } + return m.summaryRefs[n-1], true +} + // clearScrollback empties the screen and the scrollback behind it. It rides // along with the first print of a job rather than being written to stdout // directly: a write inside a Cmd is not ordered against the event loop, so it @@ -1760,8 +1786,9 @@ func (m *simulateModel) renderSummary() string { ) wrapWidth := proseWidth(m.width, 6) + var refs summaryRefIndex link := func(text string) string { - return linkSummaryRefs(text, m.projectID(), m.runID) + return linkSummaryRefs(text, m.projectID(), m.runID, &refs) } if summary.GoingWell != "" { @@ -1811,6 +1838,10 @@ func (m *simulateModel) renderSummary() string { b.WriteString("\n") } + // what the digit keys resolve to, recorded as the labels are rendered so the + // two cannot disagree + m.summaryRefs = refs.targets + return b.String() } @@ -1872,8 +1903,17 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { } } toolOpenedAgentBlock = false - for _, line := range wrapLines(text, wrapWidth) { - b.WriteString(" " + line + "\n") + cited := msg.Id != "" && msg.Id == m.refItemID + for i, line := range wrapLines(text, wrapWidth) { + b.WriteString(" " + line) + // a jump lands at the top of the printed job, so the cited turn + // says so where it prints. The mark rides the text, which every + // message has, and not the speaker header, which a message + // continuing an open agent block never prints. + if i == 0 && cited { + b.WriteString(" " + summaryRefStyle().Render("◀ cited")) + } + b.WriteString("\n") } case *agent.ChatContext_ChatItem_FunctionCall: fc := v.FunctionCall @@ -2096,6 +2136,9 @@ func (m *simulateModel) renderHint() string { default: // the collapsed description block already carries "(press d to expand)" nav := "↑↓ navigate · →/ENTER detail" + if len(m.summaryRefs) > 0 { + nav += " · 1-9 cited turn" + } if m.pageOverflow || m.viewScrollOff > 0 { nav += " · PgUp/PgDn page" } From 89a2965faeaa7450ec8adcd9578e485e8f9aaf45 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 14 Aug 2026 09:59:25 -0400 Subject: [PATCH 3/3] fix(simulate): mark a cited tool call, and say when a citation is not there Marking only ran over chat messages, so a citation naming a tool call went to a transcript with nothing marked in it. A function call carries an id like the messages do, so it marks the same way; the mark rides outside writeToolItem's dimming, which the payload it annotates is under. A summary also cites items that are not in the history it summarized. The jump still lands on the job it named, so the transcript now says the cited turn is not in it rather than leaving a reader scanning for a mark that was never coming. --- cmd/lk/simulate_tui.go | 53 +++++++++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index d5ac2b5ee..33800b37e 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -1868,6 +1868,9 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { // the chat history after the user message that triggered them and before // the agent's spoken reply. Open an Agent block for them when needed so // they don't render under the user's header. + // whether the citation a jump followed was found among the items below + cited := false + currentSpeaker := "" toolOpenedAgentBlock := false ensureAgentBlock := func() { @@ -1903,22 +1906,30 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { } } toolOpenedAgentBlock = false - cited := msg.Id != "" && msg.Id == m.refItemID + isCited := m.citedItem(msg.Id) + if isCited { + cited = true + } for i, line := range wrapLines(text, wrapWidth) { b.WriteString(" " + line) // a jump lands at the top of the printed job, so the cited turn // says so where it prints. The mark rides the text, which every // message has, and not the speaker header, which a message // continuing an open agent block never prints. - if i == 0 && cited { - b.WriteString(" " + summaryRefStyle().Render("◀ cited")) + if i == 0 && isCited { + b.WriteString(citedMark()) } b.WriteString("\n") } case *agent.ChatContext_ChatItem_FunctionCall: fc := v.FunctionCall ensureAgentBlock() - writeToolItem(&b, fmt.Sprintf("ƒ %s(%s)", fc.Name, m.toolArguments(fc.Arguments)), wrapWidth) + mark := "" + if m.citedItem(fc.Id) { + cited = true + mark = citedMark() + } + writeToolItem(&b, fmt.Sprintf("ƒ %s(%s)", fc.Name, m.toolArguments(fc.Arguments)), wrapWidth, mark) case *agent.ChatContext_ChatItem_FunctionCallOutput: if !m.showToolDetail { continue @@ -1929,7 +1940,7 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { continue } ensureAgentBlock() - writeToolItem(&b, "→ "+output, wrapWidth) + writeToolItem(&b, "→ "+output, wrapWidth, "") case *agent.ChatContext_ChatItem_AgentHandoff: h := v.AgentHandoff old := "" @@ -1941,9 +1952,29 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { b.WriteString("\n") } } + + // A summary can cite an item that is not in the history it summarized, and + // the jump still lands on the job it named. Saying so beats an unmarked + // transcript the reader scans for a mark that was never coming. + if m.refItemID != "" && !cited { + b.WriteString("\n") + b.WriteString(dimStyle.Render(" the cited turn is not in this transcript")) + b.WriteString("\n") + } + return b.String() } +// citedMark labels the turn a jump followed. +func citedMark() string { + return " " + summaryRefStyle().Render("◀ cited") +} + +// citedItem reports whether id is the chat item the open jump cited. +func (m *simulateModel) citedItem(id string) bool { + return id != "" && id == m.refItemID +} + // toolArguments renders a call's arguments for the transcript. Collapsed, an // argument list stands for itself with an ellipsis: the call's name is what // reads the conversation, and full JSON payloads bury it. @@ -1960,14 +1991,20 @@ func (m *simulateModel) toolArguments(arguments string) string { // writeToolItem appends one tool line to b, wrapped to the transcript's measure // with its continuations indented under the marker, so a long output stays -// readable as a block instead of one run-on row. -func writeToolItem(b *strings.Builder, text string, wrapWidth int) { - for i, line := range wrapLines(text, wrapWidth-2) { +// readable as a block instead of one run-on row. suffix rides the last line +// outside the dimming, for a mark that has to carry over the payload it +// annotates. +func writeToolItem(b *strings.Builder, text string, wrapWidth int, suffix string) { + lines := wrapLines(text, wrapWidth-2) + for i, line := range lines { indent := " " if i > 0 { indent = " " } b.WriteString(dimStyle.Render(indent + line)) + if i == len(lines)-1 { + b.WriteString(suffix) + } b.WriteString("\n") } }