Skip to content

Commit 248b65a

Browse files
committed
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.
1 parent fca3a0e commit 248b65a

3 files changed

Lines changed: 125 additions & 14 deletions

File tree

cmd/lk/simulate_refs.go

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package main
1616

1717
import (
18+
"fmt"
1819
"regexp"
1920
"strings"
2021
"unicode"
@@ -40,16 +41,51 @@ func summaryRefStyle() lipgloss.Style {
4041
return lipgloss.NewStyle().Foreground(util.Brand()).Underline(true)
4142
}
4243

43-
// linkSummaryRefs replaces each <ref> in summary prose with its quoted text as
44-
// a clickable link to the cited chat item. A ref missing a job, or a run with
45-
// no dashboard URL, degrades to the quoted text alone.
46-
func linkSummaryRefs(text, projectID, runID string) string {
44+
// A citation's number is an invitation to press that digit, so only as many
45+
// citations as there are digits to press carry one.
46+
const maxNumberedSummaryRefs = 9
47+
48+
// summaryRefTarget is the chat item a numbered citation points at.
49+
type summaryRefTarget struct {
50+
job string
51+
item string
52+
}
53+
54+
// summaryRefIndex numbers citations as they are rendered. The number a reader
55+
// sees has to select the same citation when pressed, so one index is threaded
56+
// through every block of a summary and numbering follows render order.
57+
type summaryRefIndex struct {
58+
targets []summaryRefTarget
59+
}
60+
61+
// add records a citation and returns its 1-based number, or false once every
62+
// digit is spoken for.
63+
func (x *summaryRefIndex) add(attrs map[string]string) (int, bool) {
64+
if len(x.targets) >= maxNumberedSummaryRefs {
65+
return 0, false
66+
}
67+
x.targets = append(x.targets, summaryRefTarget{job: attrs["job"], item: attrs["item"]})
68+
return len(x.targets), true
69+
}
70+
71+
// linkSummaryRefs replaces each <ref> in summary prose with its quoted text,
72+
// numbered so the digit keys can open the cited turn, and hyperlinked to the
73+
// cited item when the run has a dashboard URL. A ref naming no job cites
74+
// nothing openable and degrades to the quoted text alone.
75+
func linkSummaryRefs(text, projectID, runID string, refs *summaryRefIndex) string {
4776
return replaceSummaryRefs(text, func(attrs map[string]string, label string) string {
48-
url := simulationItemDashboardURL(projectID, runID, attrs["job"], attrs["item"])
49-
if url == "" {
77+
if attrs["job"] == "" {
5078
return label
5179
}
52-
return util.Hyperlink(url, summaryRefStyle().Render(label))
80+
n, ok := refs.add(attrs)
81+
if !ok {
82+
return label
83+
}
84+
rendered := summaryRefStyle().Render(label)
85+
if url := simulationItemDashboardURL(projectID, runID, attrs["job"], attrs["item"]); url != "" {
86+
rendered = util.Hyperlink(url, rendered)
87+
}
88+
return rendered + dimStyle.Render(fmt.Sprintf(" [%d]", n))
5389
})
5490
}
5591

cmd/lk/simulate_refs_test.go

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ func TestStripSummaryRefs(t *testing.T) {
4141
}
4242

4343
func TestLinkSummaryRefs(t *testing.T) {
44-
linked := linkSummaryRefs(refProse, "proj", "run")
44+
var refs summaryRefIndex
45+
linked := linkSummaryRefs(refProse, "proj", "run", &refs)
4546

4647
require.NotContains(t, linked, "<ref")
4748
require.NotContains(t, linked, "</ref>")
@@ -50,10 +51,41 @@ func TestLinkSummaryRefs(t *testing.T) {
5051
require.Contains(t, linked, "runs/run?job=SRJ_Bzb9ZaoJFJyp&item=item_13b90227fe38")
5152
require.Contains(t, linked, `"I've had a few, sure"`)
5253
require.Equal(t, 2, strings.Count(linked, "\x1b]8;;"+dashboardBaseURL()))
54+
55+
// the number a label carries selects the citation recorded under it
56+
require.Contains(t, linked, "[1]")
57+
require.Contains(t, linked, "[2]")
58+
require.Equal(t, []summaryRefTarget{
59+
{job: "SRJ_Bzb9ZaoJFJyp", item: "item_dd0ee81187bd"},
60+
{job: "SRJ_Bzb9ZaoJFJyp", item: "item_13b90227fe38"},
61+
}, refs.targets)
5362
}
5463

5564
func TestLinkSummaryRefsWithoutTarget(t *testing.T) {
56-
// no project or run to link to, and a ref with no job: quoted text only
57-
require.Equal(t, stripSummaryRefs(refProse), linkSummaryRefs(refProse, "", ""))
58-
require.Equal(t, "quoted", linkSummaryRefs(`<ref item="item_x">quoted</ref>`, "proj", "run"))
65+
// a ref naming no job cites nothing that can be opened: quoted text alone
66+
var unopenable summaryRefIndex
67+
require.Equal(t, "quoted", linkSummaryRefs(`<ref item="item_x">quoted</ref>`, "proj", "run", &unopenable))
68+
require.Empty(t, unopenable.targets)
69+
70+
// with no dashboard URL the job is still openable from the TUI, so the
71+
// citation keeps its number and loses only the hyperlink
72+
var local summaryRefIndex
73+
linked := linkSummaryRefs(refProse, "", "", &local)
74+
require.NotContains(t, linked, "\x1b]8;;")
75+
require.Contains(t, linked, "[1]")
76+
require.Len(t, local.targets, 2)
77+
}
78+
79+
func TestSummaryRefIndexStopsAtTheLastDigit(t *testing.T) {
80+
var b strings.Builder
81+
for range maxNumberedSummaryRefs + 2 {
82+
b.WriteString(`<ref job="J" item="i">q</ref>`)
83+
}
84+
85+
var refs summaryRefIndex
86+
linked := linkSummaryRefs(b.String(), "proj", "run", &refs)
87+
88+
require.Len(t, refs.targets, maxNumberedSummaryRefs)
89+
require.Contains(t, linked, "[9]")
90+
require.NotContains(t, linked, "[10]")
5991
}

cmd/lk/simulate_tui.go

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,12 @@ type simulateModel struct {
217217

218218
cursor int
219219
detailJobID string
220+
// The summary's citations, in the order their numbers were rendered, so a
221+
// digit key resolves to the turn its label points at. refItemID is the chat
222+
// item a jump cited, marked when the job view prints because printed
223+
// scrollback cannot be scrolled to it.
224+
summaryRefs []summaryRefTarget
225+
refItemID string
220226
// The open job's view is printed into the terminal's own scrollback instead
221227
// of being windowed in the live region; detailPrinted is what has already
222228
// 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) {
953959
m.viewScrollOff += pageScroll // clamped on render
954960
}
955961
}
962+
// A citation's number opens the turn it cites. Only live on the list view,
963+
// which is where the numbered summary is on screen to read them off.
964+
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
965+
if m.detailJobID == "" {
966+
if ref, ok := m.summaryRef(key); ok {
967+
m.detailJobID = ref.job
968+
m.refItemID = ref.item
969+
return m, m.openDetailCmd()
970+
}
971+
}
956972
// j and l sit either side of k on the home row, so they double for the
957973
// left/right arrows without reaching for them.
958974
case "enter", "right", "l":
@@ -1673,9 +1689,19 @@ func (m *simulateModel) openDetailCmd() tea.Cmd {
16731689
func (m *simulateModel) closeDetailCmd() tea.Cmd {
16741690
m.detailJobID = ""
16751691
m.detailPrinted = ""
1692+
m.refItemID = ""
16761693
return tea.EnterAltScreen
16771694
}
16781695

1696+
// summaryRef resolves a digit key to the citation whose label carries it.
1697+
func (m *simulateModel) summaryRef(key string) (summaryRefTarget, bool) {
1698+
n := int(key[0] - '0')
1699+
if n < 1 || n > len(m.summaryRefs) {
1700+
return summaryRefTarget{}, false
1701+
}
1702+
return m.summaryRefs[n-1], true
1703+
}
1704+
16791705
// clearScrollback empties the screen and the scrollback behind it. It rides
16801706
// along with the first print of a job rather than being written to stdout
16811707
// directly: a write inside a Cmd is not ordered against the event loop, so it
@@ -1760,8 +1786,9 @@ func (m *simulateModel) renderSummary() string {
17601786
)
17611787

17621788
wrapWidth := proseWidth(m.width, 6)
1789+
var refs summaryRefIndex
17631790
link := func(text string) string {
1764-
return linkSummaryRefs(text, m.projectID(), m.runID)
1791+
return linkSummaryRefs(text, m.projectID(), m.runID, &refs)
17651792
}
17661793

17671794
if summary.GoingWell != "" {
@@ -1811,6 +1838,10 @@ func (m *simulateModel) renderSummary() string {
18111838
b.WriteString("\n")
18121839
}
18131840

1841+
// what the digit keys resolve to, recorded as the labels are rendered so the
1842+
// two cannot disagree
1843+
m.summaryRefs = refs.targets
1844+
18141845
return b.String()
18151846
}
18161847

@@ -1872,8 +1903,17 @@ func (m *simulateModel) renderChatTranscript(jobID string) string {
18721903
}
18731904
}
18741905
toolOpenedAgentBlock = false
1875-
for _, line := range wrapLines(text, wrapWidth) {
1876-
b.WriteString(" " + line + "\n")
1906+
cited := msg.Id != "" && msg.Id == m.refItemID
1907+
for i, line := range wrapLines(text, wrapWidth) {
1908+
b.WriteString(" " + line)
1909+
// a jump lands at the top of the printed job, so the cited turn
1910+
// says so where it prints. The mark rides the text, which every
1911+
// message has, and not the speaker header, which a message
1912+
// continuing an open agent block never prints.
1913+
if i == 0 && cited {
1914+
b.WriteString(" " + summaryRefStyle().Render("◀ cited"))
1915+
}
1916+
b.WriteString("\n")
18771917
}
18781918
case *agent.ChatContext_ChatItem_FunctionCall:
18791919
fc := v.FunctionCall
@@ -2096,6 +2136,9 @@ func (m *simulateModel) renderHint() string {
20962136
default:
20972137
// the collapsed description block already carries "(press d to expand)"
20982138
nav := "↑↓ navigate · →/ENTER detail"
2139+
if len(m.summaryRefs) > 0 {
2140+
nav += " · 1-9 cited turn"
2141+
}
20992142
if m.pageOverflow || m.viewScrollOff > 0 {
21002143
nav += " · PgUp/PgDn page"
21012144
}

0 commit comments

Comments
 (0)