Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 43 additions & 7 deletions cmd/lk/simulate_refs.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package main

import (
"fmt"
"regexp"
"strings"
"unicode"
Expand All @@ -40,16 +41,51 @@ func summaryRefStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(util.Brand()).Underline(true)
}

// linkSummaryRefs replaces each <ref> 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 <ref> 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))
})
}

Expand Down
40 changes: 36 additions & 4 deletions cmd/lk/simulate_refs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<ref")
require.NotContains(t, linked, "</ref>")
Expand All @@ -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(`<ref item="item_x">quoted</ref>`, "proj", "run"))
// a ref naming no job cites nothing that can be opened: quoted text alone
var unopenable summaryRefIndex
require.Equal(t, "quoted", linkSummaryRefs(`<ref item="item_x">quoted</ref>`, "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(`<ref job="J" item="i">q</ref>`)
}

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]")
}
96 changes: 88 additions & 8 deletions cmd/lk/simulate_tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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()
}

Expand All @@ -1837,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() {
Expand Down Expand Up @@ -1872,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
Expand All @@ -1889,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 := ""
Expand All @@ -1901,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.
Expand All @@ -1920,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")
}
}
Expand Down Expand Up @@ -2096,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"
}
Expand Down
Loading