diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index e45278ce..0588e4c1 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 00000000..3eba180a --- /dev/null +++ b/cmd/lk/simulate_refs.go @@ -0,0 +1,127 @@ +// 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 ( + "fmt" + "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) +} + +// 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 { + if attrs["job"] == "" { + return 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)) + }) +} + +// 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 00000000..a07058c7 --- /dev/null +++ b/cmd/lk/simulate_refs_test.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 ( + "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) { + var refs summaryRefIndex + linked := linkSummaryRefs(refProse, "proj", "run", &refs) + + 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())) + + // 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) { + // 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_report.go b/cmd/lk/simulate_report.go index c92d454a..5344ab58 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 d7c57217..33800b37 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,11 +1786,15 @@ 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, &refs) + } 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 +1804,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 +1820,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 +1829,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") } @@ -1808,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() } @@ -1834,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() { @@ -1869,13 +1906,30 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { } } toolOpenedAgentBlock = false - for _, line := range wrapLines(text, wrapWidth) { - b.WriteString(" " + line + "\n") + 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 && 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 @@ -1886,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 := "" @@ -1898,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. @@ -1917,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") } } @@ -2093,6 +2173,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" }