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..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,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() } @@ -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() { @@ -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 @@ -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 := "" @@ -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. @@ -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") } } @@ -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" }