From 248b65aceb707cc89f8cc6dfd47a13f379fdc02d Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 14 Aug 2026 09:48:36 -0400 Subject: [PATCH 1/2] 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 1ba9c90b..3eba180a 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 e393264e..a07058c7 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 eed83843..d5ac2b5e 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 17b310f9fe2ad5d5c887c531c22f1a4e22309aac Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 14 Aug 2026 09:59:25 -0400 Subject: [PATCH 2/2] 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 d5ac2b5e..33800b37 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") } }