merge: integrate feat/backlog-burndown into master
master had advanced 16 commits past feat/backlog-burndown's base, and the two branches independently built four of the same features. Resolved 26 conflicts. Overlap features — kept master's implementation (more complete / production-wired / more robust), dropped the feature branch's parallel constellation: - llama-server health probe: kept master's event-store-backed tps probe; dropped the branch's LlamaLivenessClient (liveness-only, throughput unwired). - event-store probe: kept master's EventStoreHealthProbe; dropped EventStoreLatencyProbe. - brief echo-back gate: kept master's BriefEchoDiff (Jaccard, tolerates rewording); dropped the branch's exact-set-diff BriefEchoComparator/Extractor. - static-first reviewer: kept master's command/exit-code gate (ProcessStaticAnalysisRunner, wired); dropped the branch's structured-finding static_check stage (no-op seam). Its structured-findings model is filed as a follow-up in BACKLOG. Feature-branch net-new work brought in and kept (master had none): - native task tracking (aggregate, agent tools wired into analyst/implementer/reviewer, dependency graph + gates, decompose, REST/CLI, TUI task board) - critique-outcome producer (role-rel §6 — master had deferred it) - stage-level plan checkpointing (C-A2, folded into runPostStageGates) - CLAUDE.md/AGENTS.md L0 standing context - cross-session grants + TUI (grant scopes/revoke, @ picker, session resume browser) Verified: full Gradle compile (all modules + tests) green; tests pass for core:events, core:kernel, infrastructure:workflow, apps:server, apps:cli, testing:integration; tui-go go build + go test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,20 +2,82 @@ package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// center composites a modal over a scrim-filled screen. Immediate-mode: the modal
|
||||
// is recomputed from state every frame, so there is no separate show/restore path
|
||||
// to desync (the "diff won't come back" bug class can't occur here).
|
||||
// center draws a modal centered over a *dimmed* copy of the screen behind it, so the
|
||||
// transcript stays faintly visible around the modal (a transparent backdrop) rather
|
||||
// than being hidden by an opaque scrim. Immediate-mode: recomputed from state every
|
||||
// frame, so there is no show/restore path to desync.
|
||||
//
|
||||
// Idempotence guard: some modal builders call center() internally and then get centered
|
||||
// again by renderOverlay. The inner call already produced a full-screen composite, so a
|
||||
// second call (input already width×height) returns it unchanged instead of dimming the
|
||||
// modal itself. Falls back to an opaque scrim when no base was stashed (defensive).
|
||||
func (m Model) center(modal string) string {
|
||||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal,
|
||||
lipgloss.WithWhitespaceBackground(m.theme.P.BgDeep),
|
||||
lipgloss.WithWhitespaceForeground(m.theme.P.BgDeep))
|
||||
if m.lastBase == "" {
|
||||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal,
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().
|
||||
Background(m.theme.P.BgDeep).Foreground(m.theme.P.BgDeep)))
|
||||
}
|
||||
if lipgloss.Height(modal) >= m.height && lipgloss.Width(modal) >= m.width {
|
||||
return modal // already a full-screen composite; don't re-dim it
|
||||
}
|
||||
return m.compositeOverlay(m.lastBase, modal)
|
||||
}
|
||||
|
||||
// compositeOverlay strips + dims base into a faint scrim, then splices the modal box
|
||||
// over it centered. ANSI-aware so the side margins keep the (dimmed) content behind.
|
||||
// base and the result are both m.width × m.height.
|
||||
func (m Model) compositeOverlay(base, modal string) string {
|
||||
w, h := m.width, m.height
|
||||
dim := lipgloss.NewStyle().Foreground(m.theme.P.Faint).Background(m.theme.P.BgDeep)
|
||||
baseLines := strings.Split(base, "\n")
|
||||
scrim := make([]string, h)
|
||||
for i := 0; i < h; i++ {
|
||||
raw := ""
|
||||
if i < len(baseLines) {
|
||||
raw = ansi.Strip(baseLines[i])
|
||||
}
|
||||
scrim[i] = dim.Render(padRightRaw(raw, w))
|
||||
}
|
||||
modalLines := strings.Split(modal, "\n")
|
||||
mw := lipgloss.Width(modal)
|
||||
top := (h - len(modalLines)) / 2
|
||||
left := (w - mw) / 2
|
||||
if top < 0 {
|
||||
top = 0
|
||||
}
|
||||
if left < 0 {
|
||||
left = 0
|
||||
}
|
||||
for r, ml := range modalLines {
|
||||
row := top + r
|
||||
if row < 0 || row >= h {
|
||||
continue
|
||||
}
|
||||
leftPart := ansi.Truncate(scrim[row], left, "")
|
||||
rightPart := ansi.TruncateLeft(scrim[row], left+mw, "")
|
||||
scrim[row] = leftPart + ml + rightPart
|
||||
}
|
||||
return strings.Join(scrim, "\n")
|
||||
}
|
||||
|
||||
// padRightRaw pads an unstyled string with spaces to exactly w columns (or truncates).
|
||||
func padRightRaw(s string, w int) string {
|
||||
switch sw := ansi.StringWidth(s); {
|
||||
case sw > w:
|
||||
return ansi.Truncate(s, w, "")
|
||||
case sw < w:
|
||||
return s + strings.Repeat(" ", w-sw)
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) modalWidth() int {
|
||||
@@ -63,6 +125,20 @@ func (m Model) renderOverlay(base string) string {
|
||||
return m.center(m.ideasModal())
|
||||
case OverlayHealth:
|
||||
return m.center(m.healthModal())
|
||||
case OverlaySessions:
|
||||
return m.center(m.sessionsModal())
|
||||
case OverlayTasks:
|
||||
return m.center(m.tasksModal())
|
||||
case OverlayFiles:
|
||||
return m.center(m.filesModal())
|
||||
case OverlayStatusbar:
|
||||
return m.center(m.statusbarModal())
|
||||
case OverlayGrants:
|
||||
return m.center(m.grantsModal())
|
||||
case OverlayGrantScope:
|
||||
return m.center(m.grantScopeModal())
|
||||
case OverlayHelp:
|
||||
return m.center(m.helpModal())
|
||||
}
|
||||
return base
|
||||
}
|
||||
@@ -72,7 +148,7 @@ func (m Model) titleLine(text string) string {
|
||||
return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Bold(true).Render(text)
|
||||
}
|
||||
|
||||
func mbg(t Theme, s string, fg lipgloss.Color) string {
|
||||
func mbg(t Theme, s string, fg color.Color) string {
|
||||
return lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(s)
|
||||
}
|
||||
|
||||
@@ -132,6 +208,13 @@ func (m Model) renderApprovalBand(h int) string {
|
||||
rightHdr := t.span("tier ", t.P.Faint) + t.span(a.Tier, t.P.Accent2) +
|
||||
t.span(" · risk ", t.P.Faint) +
|
||||
lipgloss.NewStyle().Foreground(riskColor).Background(t.P.Bg).Bold(true).Render(strings.ToUpper(a.Risk))
|
||||
// When more than one gate is queued, show "approval i/n" so the operator knows
|
||||
// there are others behind this one (↑/↓ to walk them) — never stack modals.
|
||||
if n := len(s.PendingQueue); n > 1 {
|
||||
rightHdr = t.span("approval ", t.P.Faint) +
|
||||
t.span(itoa(s.PendingIdx+1)+"/"+itoa(n), t.P.Accent) +
|
||||
t.span(" · ", t.P.Faint) + rightHdr
|
||||
}
|
||||
|
||||
innerH := h - 2
|
||||
ratBlock := 0
|
||||
@@ -218,8 +301,19 @@ func (m Model) approvalActions() string {
|
||||
hint("enter", "approve + send note"), hint("esc", "keep note, decide below"),
|
||||
}, gap)
|
||||
}
|
||||
parts := []string{hint("a", "approve"), hint("A", "auto"), hint("s", "steer"), hint("r", "reject")}
|
||||
if s := m.session(m.selectedID); s != nil && s.Pending != nil &&
|
||||
s := m.session(m.selectedID)
|
||||
// Armed T3+ approve: a single decisive line so an accidental keystroke can't slip
|
||||
// a destructive action through — the confirm key must be pressed again.
|
||||
if m.approvalArmed && s != nil && s.Pending != nil {
|
||||
warn := lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Bold(true).
|
||||
Render("press y again to confirm " + s.Pending.Tier + " approval")
|
||||
return warn + gap + hint("any other key", "cancel")
|
||||
}
|
||||
parts := []string{hint("y", "approve"), hint("n", "reject"), hint("e", "steer"), hint("A", "auto")}
|
||||
if s != nil && len(s.PendingQueue) > 1 {
|
||||
parts = append(parts, hint("↑↓", "queue"))
|
||||
}
|
||||
if s != nil && s.Pending != nil &&
|
||||
(isUnifiedDiff(s.Pending.Preview) || isCommandApproval(s.Pending)) {
|
||||
parts = append(parts, hint("^x", "fullscreen"))
|
||||
}
|
||||
@@ -264,6 +358,20 @@ func (m Model) diffMaxScroll() int {
|
||||
return max
|
||||
}
|
||||
|
||||
// scrollDiff moves the diff/preview/command modal offset by delta rows, clamped to
|
||||
// [0, diffMaxScroll] so paging past either end lands exactly on the edge (no dead
|
||||
// presses unwinding an overshoot — same contract as the OUTPUT free-scroll).
|
||||
func (m *Model) scrollDiff(delta int) {
|
||||
off := m.diffScrollOffset + delta
|
||||
if mx := m.diffMaxScroll(); off > mx {
|
||||
off = mx
|
||||
}
|
||||
if off < 0 {
|
||||
off = 0
|
||||
}
|
||||
m.diffScrollOffset = off
|
||||
}
|
||||
|
||||
func (m Model) diffModal() string {
|
||||
if cmd := m.pendingCommand(); cmd != nil {
|
||||
return m.commandModal(cmd)
|
||||
@@ -301,7 +409,7 @@ func (m Model) diffModal() string {
|
||||
for _, ln := range lines {
|
||||
b.WriteString(ln + "\n")
|
||||
}
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"^x/esc", "close"}}))
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "line"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"^x/esc", "close"}}))
|
||||
|
||||
modal := t.Overlay.Width(w).Render(b.String())
|
||||
return m.center(modal)
|
||||
@@ -335,7 +443,7 @@ func (m Model) commandModal(a *Approval) string {
|
||||
for i := off; i < end; i++ {
|
||||
b.WriteString(lines[i] + "\n")
|
||||
}
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"^x/esc", "close"}}))
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "line"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"^x/esc", "close"}}))
|
||||
return m.center(t.Overlay.Width(w).Render(b.String()))
|
||||
}
|
||||
|
||||
@@ -350,8 +458,25 @@ func (m Model) eventInspectorModal() string {
|
||||
b.WriteString(mbg(t, " ("+itoa(m.overlayEventIdx+1)+"/"+itoa(len(evs))+")", t.P.Faint))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
// Filter line (shown while editing or when a query is set): a `/` prompt over the query.
|
||||
if m.eventFilterTyping || m.eventFilter != "" {
|
||||
caret := mbg(t, "", t.P.BgPanel)
|
||||
if m.eventFilterTyping && m.caretVisible() {
|
||||
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
|
||||
}
|
||||
prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("/ ")
|
||||
if m.eventFilter == "" {
|
||||
b.WriteString(prompt + caret + mbg(t, "type to filter events…", t.P.Faint) + "\n\n")
|
||||
} else {
|
||||
b.WriteString(prompt + mbg(t, m.eventFilter, t.P.FgStrong) + caret + "\n\n")
|
||||
}
|
||||
}
|
||||
if len(evs) == 0 {
|
||||
b.WriteString(mbg(t, "no events", t.P.Faint))
|
||||
msg := "no events"
|
||||
if m.eventFilter != "" {
|
||||
msg = "no events match \"" + m.eventFilter + "\""
|
||||
}
|
||||
b.WriteString(mbg(t, msg, t.P.Faint))
|
||||
return m.center(t.Overlay.Width(w).Render(b.String()))
|
||||
}
|
||||
// Budget the plain detail so the styled row never needs ANSI-aware truncation
|
||||
@@ -395,7 +520,7 @@ func (m Model) eventInspectorModal() string {
|
||||
mbg(t, " "+truncate(e.Detail, detailBudget), t.P.Dim)
|
||||
b.WriteString(row + "\n")
|
||||
}
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"esc", "close"}}))
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"/", "filter"}, {"c", "clear"}, {"esc", "close"}}))
|
||||
|
||||
modal := t.Overlay.Width(w).Render(b.String())
|
||||
return m.center(modal)
|
||||
@@ -432,8 +557,13 @@ func (m Model) paletteModal() string {
|
||||
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
|
||||
titleFg = t.P.FgStrong
|
||||
}
|
||||
b.WriteString(marker +
|
||||
lipgloss.NewStyle().Foreground(titleFg).Background(t.P.BgPanel).Render(padRaw(c.title, 18)) +
|
||||
// keybind column: the bare-key shortcut, so the palette teaches the shortcuts.
|
||||
keyCell := mbg(t, padRaw("", 4), t.P.BgPanel)
|
||||
if c.key != "" {
|
||||
keyCell = lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(padRaw(c.key, 4))
|
||||
}
|
||||
b.WriteString(marker + keyCell +
|
||||
lipgloss.NewStyle().Foreground(titleFg).Background(t.P.BgPanel).Render(padRaw(c.title, 16)) +
|
||||
mbg(t, " "+c.hint, t.P.Faint) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "run"}, {"esc", "close"}}))
|
||||
@@ -441,6 +571,312 @@ func (m Model) paletteModal() string {
|
||||
return m.center(modal)
|
||||
}
|
||||
|
||||
// filesModal is the `@` file-reference picker: a filter line over the session workspace's
|
||||
// paths, windowed around the selection; enter inserts `@path` into the chat input.
|
||||
func (m Model) filesModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
files := m.filteredFiles()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("@ file reference") + "\n\n")
|
||||
|
||||
caret := mbg(t, " ", t.P.BgPanel)
|
||||
if m.caretVisible() {
|
||||
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
|
||||
}
|
||||
prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("@ ")
|
||||
if m.fileFilter == "" {
|
||||
b.WriteString(prompt + caret + mbg(t, "type to filter files…", t.P.Faint) + "\n\n")
|
||||
} else {
|
||||
b.WriteString(prompt + mbg(t, m.fileFilter, t.P.FgStrong) + caret + "\n\n")
|
||||
}
|
||||
|
||||
switch {
|
||||
case m.filesLoading:
|
||||
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
|
||||
case len(m.files) == 0:
|
||||
b.WriteString(mbg(t, " no workspace files (is a workspace bound?)", t.P.Faint) + "\n")
|
||||
case len(files) == 0:
|
||||
b.WriteString(mbg(t, " no matching files", t.P.Faint) + "\n")
|
||||
default:
|
||||
const maxRows = 12
|
||||
start := 0
|
||||
if m.fileIndex >= maxRows {
|
||||
start = m.fileIndex - maxRows + 1
|
||||
}
|
||||
end := start + maxRows
|
||||
if end > len(files) {
|
||||
end = len(files)
|
||||
}
|
||||
for i := start; i < end; i++ {
|
||||
marker := mbg(t, " ", t.P.BgPanel)
|
||||
fg := t.P.Fg
|
||||
if i == m.fileIndex {
|
||||
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
|
||||
fg = t.P.FgStrong
|
||||
}
|
||||
b.WriteString(marker +
|
||||
lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(clip(files[i], w-6)) + "\n")
|
||||
}
|
||||
if len(files) > maxRows {
|
||||
b.WriteString(mbg(t, " "+itoa(len(files))+" matches", t.P.Faint) + "\n")
|
||||
}
|
||||
}
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "insert"}, {"esc", "close"}}))
|
||||
modal := t.Overlay.Width(w).Render(b.String())
|
||||
return m.center(modal)
|
||||
}
|
||||
|
||||
// statusbarModal toggles which status-bar segments are shown. Choices persist to the local
|
||||
// tui-prefs.json so the bar stays as configured across launches.
|
||||
func (m Model) statusbarModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("status bar") + "\n\n")
|
||||
b.WriteString(mbg(t, " show / hide segments (correx, connection, session name stay)", t.P.Faint) + "\n\n")
|
||||
|
||||
for i, seg := range statusSegments {
|
||||
marker := mbg(t, " ", t.P.BgPanel)
|
||||
labelFg := t.P.Fg
|
||||
if i == m.sbIndex {
|
||||
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
|
||||
labelFg = t.P.FgStrong
|
||||
}
|
||||
box := lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.BgPanel).Render("[✓] ")
|
||||
if !m.sbShow(seg.id) {
|
||||
box = mbg(t, "[ ] ", t.P.Faint)
|
||||
}
|
||||
b.WriteString(marker + box +
|
||||
lipgloss.NewStyle().Foreground(labelFg).Background(t.P.BgPanel).Render(seg.label) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "move"}, {"space", "toggle"}, {"esc", "close"}}))
|
||||
modal := t.Overlay.Width(w).Render(b.String())
|
||||
return m.center(modal)
|
||||
}
|
||||
|
||||
// grantsModal lists the active standing (PROJECT/GLOBAL) grants and lets the operator revoke one.
|
||||
// These are cross-session auto-approvals, so the board opens from anywhere.
|
||||
func (m Model) grantsModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("standing grants") + "\n\n")
|
||||
b.WriteString(mbg(t, " cross-session auto-approvals in force (tool-bound)", t.P.Faint) + "\n\n")
|
||||
|
||||
switch {
|
||||
case m.grantsLoading:
|
||||
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
|
||||
case len(m.grants) == 0:
|
||||
b.WriteString(mbg(t, " none — press A on an approval to grant project / global", t.P.Faint) + "\n")
|
||||
default:
|
||||
for i, g := range m.grants {
|
||||
marker := mbg(t, " ", t.P.BgPanel)
|
||||
fg := t.P.Fg
|
||||
if i == m.grantIndex {
|
||||
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
|
||||
fg = t.P.FgStrong
|
||||
}
|
||||
scope := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render(padRaw(g.Scope, 8))
|
||||
tool := lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(padRaw(g.ToolName, 16))
|
||||
tiers := mbg(t, "≤"+strings.Join(g.Tiers, ","), t.P.Faint)
|
||||
b.WriteString(marker + scope + tool + tiers)
|
||||
if g.Scope == "PROJECT" && g.ProjectID != "" {
|
||||
b.WriteString(mbg(t, " "+clip(g.ProjectID, w-52), t.P.Faint))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"x", "revoke"}, {"G/esc", "close"}}))
|
||||
modal := t.Overlay.Width(w).Render(b.String())
|
||||
return m.center(modal)
|
||||
}
|
||||
|
||||
// grantScopeModal is the A (approve-always) breadth picker: choose how widely to stop being asked
|
||||
// for the pending tool — this session, this project, or everywhere.
|
||||
func (m Model) grantScopeModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
|
||||
tool := ""
|
||||
if m.grantFor != nil {
|
||||
tool = m.grantFor.ToolName
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("approve always — choose scope") + "\n\n")
|
||||
b.WriteString(mbg(t, " stop asking for ", t.P.Faint) +
|
||||
lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.BgPanel).Render(tool) + "\n\n")
|
||||
|
||||
for i, opt := range grantScopeOptions() {
|
||||
marker := mbg(t, " ", t.P.BgPanel)
|
||||
labelFg := t.P.Fg
|
||||
if i == m.grantScopeIndex {
|
||||
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
|
||||
labelFg = t.P.FgStrong
|
||||
}
|
||||
key := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render(padRaw(opt.key, 3))
|
||||
title := lipgloss.NewStyle().Foreground(labelFg).Background(t.P.BgPanel).Render(padRaw(opt.title, 16))
|
||||
b.WriteString(marker + key + title + mbg(t, opt.hint, t.P.Faint) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "move"}, {"enter", "grant"}, {"esc", "cancel"}}))
|
||||
modal := t.Overlay.Width(w).Render(b.String())
|
||||
return m.center(modal)
|
||||
}
|
||||
|
||||
// helpModal is the keybinding cheat-sheet (?), grouped by context. Any key dismisses it.
|
||||
// modalBodyRows is how many body lines a scrollable fixed-content modal shows: the
|
||||
// screen height minus the modal chrome (border 2 + padding 2 + title block 2 + hint
|
||||
// block 2). Sized so a modal that fits shows in full and a taller one scrolls rather
|
||||
// than clipping its bottom off-screen (the old compositeOverlay behaviour).
|
||||
func (m Model) modalBodyRows() int {
|
||||
if r := m.height - 8; r > 3 {
|
||||
return r
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
// scrollableModalMax is the largest scroll offset for a body of n lines, anchoring the
|
||||
// last page to the bottom.
|
||||
func (m Model) scrollableModalMax(n int) int {
|
||||
if max := n - m.modalBodyRows(); max > 0 {
|
||||
return max
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// activeModalBody returns the body lines of whichever scrollable fixed-content modal is
|
||||
// open, so the key handler can clamp m.modalScroll against the same content it renders.
|
||||
func (m Model) activeModalBody() []string {
|
||||
switch m.overlay {
|
||||
case OverlayHelp:
|
||||
return m.helpBody()
|
||||
case OverlayStats:
|
||||
if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID {
|
||||
return nil
|
||||
}
|
||||
return m.statsBody()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scrollModal moves the active fixed-content modal's body offset by delta lines, clamped
|
||||
// to [0, scrollableModalMax] — same contract as the diff/output scrollers.
|
||||
func (m *Model) scrollModal(delta int) {
|
||||
max := m.scrollableModalMax(len(m.activeModalBody()))
|
||||
off := m.modalScroll + delta
|
||||
if off > max {
|
||||
off = max
|
||||
}
|
||||
if off < 0 {
|
||||
off = 0
|
||||
}
|
||||
m.modalScroll = off
|
||||
}
|
||||
|
||||
// renderScrollModal lays out a title + scrollable body + pinned hint inside the modal box,
|
||||
// windowing body by m.modalScroll so the box never exceeds the screen. A position indicator
|
||||
// appears in the title row whenever the body is taller than the viewport.
|
||||
func (m Model) renderScrollModal(title, titleSuffix string, body []string, hint [][2]string) string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
rows := m.modalBodyRows()
|
||||
off := m.modalScroll
|
||||
if mx := m.scrollableModalMax(len(body)); off > mx {
|
||||
off = mx
|
||||
}
|
||||
if off < 0 {
|
||||
off = 0
|
||||
}
|
||||
end := off + rows
|
||||
if end > len(body) {
|
||||
end = len(body)
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine(title))
|
||||
if titleSuffix != "" {
|
||||
b.WriteString(mbg(t, titleSuffix, t.P.Faint))
|
||||
}
|
||||
if len(body) > rows {
|
||||
b.WriteString(mbg(t, " ("+itoa(off+1)+"-"+itoa(end)+"/"+itoa(len(body))+")", t.P.Faint))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
for i := off; i < end; i++ {
|
||||
b.WriteString(body[i] + "\n")
|
||||
}
|
||||
b.WriteString("\n" + modalHints(t, hint))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
|
||||
// helpBody is the keybinding cheat-sheet as discrete lines (so renderScrollModal can window it).
|
||||
func (m Model) helpBody() []string {
|
||||
t := m.theme
|
||||
type kb struct{ key, desc string }
|
||||
var out []string
|
||||
section := func(title string, rows []kb) {
|
||||
out = append(out, lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(title))
|
||||
for _, r := range rows {
|
||||
key := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render(padRaw(r.key, 16))
|
||||
out = append(out, " "+key+mbg(t, r.desc, t.P.Fg))
|
||||
}
|
||||
out = append(out, "")
|
||||
}
|
||||
section("navigate", []kb{
|
||||
{"↑↓ / j k", "move the session / list selection"},
|
||||
{"enter", "open the selected session"},
|
||||
{"Tab / w", "idle: cycle launch target (chat / workflow)"},
|
||||
{"/", "filter the session list"},
|
||||
{"^↑ / ^↓", "jump to your previous / next message"},
|
||||
{"PgUp / PgDn", "scroll output (^u / ^d half-page)"},
|
||||
{"esc", "drop selection / scroll → follow newest"},
|
||||
{"l", "back to the session list"},
|
||||
})
|
||||
section("session", []kb{
|
||||
{"i", "compose a message"},
|
||||
{"d", "right panel: events / changes / off"},
|
||||
{"s", "toggle chat / steering mode"},
|
||||
{"w", "show workflows (idle screen)"},
|
||||
{"y", "copy the selected message (OSC52)"},
|
||||
{"^x", "open the latest diff / preview"},
|
||||
{"c", "cancel the running session"},
|
||||
{"a", "re-open a dismissed approval gate"},
|
||||
{"q", "quit"},
|
||||
})
|
||||
section("overlays", []kb{
|
||||
{"p", "command palette (all shortcuts)"},
|
||||
{"?", "this keybindings help"},
|
||||
{"e", "event inspector (/ to filter)"},
|
||||
{"t / v", "tools / artifacts"},
|
||||
{"S / R", "session stats / resume past sessions"},
|
||||
{"T", "task board (across projects)"},
|
||||
{"m / g", "swap model / edit config"},
|
||||
{"G / I", "grants / idea board"},
|
||||
})
|
||||
section("approval band", []kb{
|
||||
{"y / a / enter", "approve once (T3+ asks again to confirm)"},
|
||||
{"n / r", "reject"},
|
||||
{"e / s", "steer (add a note)"},
|
||||
{"A", "approve-always — choose scope"},
|
||||
{"↑ / ↓", "walk the pending queue"},
|
||||
{"^x", "fullscreen the diff / command preview"},
|
||||
{"esc", "dismiss for now (a re-opens)"},
|
||||
})
|
||||
section("diff / preview viewer (^x)", []kb{
|
||||
{"↑ / ↓", "scroll a line"},
|
||||
{"PgUp / PgDn", "scroll a page (^u / ^d half)"},
|
||||
{"g / G", "jump to top / end"},
|
||||
{"^x / esc", "close"},
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (m Model) helpModal() string {
|
||||
return m.renderScrollModal("keybindings", "", m.helpBody(),
|
||||
[][2]string{{"↑↓", "scroll"}, {"esc", "close"}})
|
||||
}
|
||||
|
||||
func (m Model) toolPaletteModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
@@ -550,6 +986,20 @@ func (m Model) artifactContentMaxScroll(w int) int {
|
||||
return max
|
||||
}
|
||||
|
||||
// scrollArtifact moves the selected artifact's content offset by delta lines, clamped to
|
||||
// the scrollable range (same contract as the diff/output scrollers).
|
||||
func (m *Model) scrollArtifact(delta int) {
|
||||
max := m.artifactContentMaxScroll(m.modalWidth() - 6)
|
||||
off := m.artifactScroll + delta
|
||||
if off > max {
|
||||
off = max
|
||||
}
|
||||
if off < 0 {
|
||||
off = 0
|
||||
}
|
||||
m.artifactScroll = off
|
||||
}
|
||||
|
||||
func (m Model) artifactsModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
@@ -628,7 +1078,7 @@ func (m Model) artifactsModal() string {
|
||||
b.WriteString(mbg(t, " "+lines[i], t.P.Fg) + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"PgUp/Dn", "scroll"}, {"v/esc", "close"}}))
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"v/esc", "close"}}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
|
||||
@@ -660,14 +1110,14 @@ func (m Model) statsModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("session stats"))
|
||||
if m.selectedID != "" {
|
||||
b.WriteString(mbg(t, " ("+shortID(m.selectedID)+")", t.P.Faint))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Loading / empty: a small fixed modal, no scroll needed.
|
||||
if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID {
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("session stats"))
|
||||
if m.selectedID != "" {
|
||||
b.WriteString(mbg(t, " ("+shortID(m.selectedID)+")", t.P.Faint))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
msg := "loading…"
|
||||
if !m.statsLoading && m.stats == nil {
|
||||
msg = "no stats for this session"
|
||||
@@ -677,15 +1127,29 @@ func (m Model) statsModal() string {
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
|
||||
suffix := ""
|
||||
if m.selectedID != "" {
|
||||
suffix = " (" + shortID(m.selectedID) + ")"
|
||||
}
|
||||
return m.renderScrollModal("session stats", suffix, m.statsBody(),
|
||||
[][2]string{{"↑↓", "scroll"}, {"S/esc", "close"}})
|
||||
}
|
||||
|
||||
// statsBody is the session-stats report as discrete lines (so renderScrollModal can window it).
|
||||
// Callers must ensure m.stats is loaded for the selected session.
|
||||
func (m Model) statsBody() []string {
|
||||
t := m.theme
|
||||
s := m.stats
|
||||
cw := w - 4 // modal inner content width (borders + padding)
|
||||
cw := m.modalWidth() - 4 // modal inner content width (borders + padding)
|
||||
var out []string
|
||||
section := func(label string) string {
|
||||
return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label)
|
||||
}
|
||||
// put clips each data line to the inner width so it never wraps on a slim modal.
|
||||
put := func(styled string) { b.WriteString(clip(styled, cw) + "\n") }
|
||||
put := func(styled string) { out = append(out, clip(styled, cw)) }
|
||||
line := func(s string) string { return mbg(t, s, t.P.Fg) }
|
||||
faint := func(s string) string { return mbg(t, s, t.P.Faint) }
|
||||
blank := func() { out = append(out, "") }
|
||||
// nameW shrinks the breakdown name column on slim terminals.
|
||||
nameW := 20
|
||||
if cw < 52 {
|
||||
@@ -695,10 +1159,10 @@ func (m Model) statsModal() string {
|
||||
// header row: duration + event count
|
||||
put(faint(" duration ") + line(humanDurMs(s.SessionDurationMs)) +
|
||||
faint(" events ") + line(itoa(int(s.EventCount))))
|
||||
b.WriteString("\n")
|
||||
blank()
|
||||
|
||||
// inference
|
||||
b.WriteString(section("Inference") + "\n")
|
||||
out = append(out, section("Inference"))
|
||||
put(line(fmt.Sprintf(" %d calls · %d+%d tok · %.1f tok/s",
|
||||
s.InferenceCount, s.PromptTokens, s.CompletionTokens, s.TokensPerSecond)))
|
||||
for i, p := range s.PerProvider {
|
||||
@@ -709,10 +1173,10 @@ func (m Model) statsModal() string {
|
||||
put(faint(fmt.Sprintf(" %-*s %d× %d tok %.1f t/s",
|
||||
nameW, padRaw(p.Provider, nameW), p.CompletedCount, p.PromptTokens+p.CompletionTokens, p.TokensPerSecond)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
blank()
|
||||
|
||||
// tools
|
||||
b.WriteString(section("Tools") + "\n")
|
||||
out = append(out, section("Tools"))
|
||||
put(line(fmt.Sprintf(" %d calls · %d ms", s.ToolCount, s.ToolMs)))
|
||||
for i, tl := range s.PerTool {
|
||||
if i >= statsBreakdownRows {
|
||||
@@ -721,35 +1185,34 @@ func (m Model) statsModal() string {
|
||||
}
|
||||
put(faint(fmt.Sprintf(" %-*s %d× %d ms", nameW, padRaw(tl.ToolName, nameW), tl.CompletedCount, tl.TotalDurationMs)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
blank()
|
||||
|
||||
// approvals
|
||||
b.WriteString(section("Approvals") + "\n")
|
||||
out = append(out, section("Approvals"))
|
||||
put(line(fmt.Sprintf(" %d req · %d res · %d pending · avg %d ms",
|
||||
s.ApprovalsRequested, s.ApprovalsResolved, s.ApprovalsPending, s.AvgApprovalWaitMs)))
|
||||
for _, tr := range s.PerTier {
|
||||
put(faint(fmt.Sprintf(" %-3s req %d res %d avg %d ms", tr.Tier, tr.RequestedCount, tr.ResolvedCount, tr.AvgWaitMs)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
blank()
|
||||
|
||||
// failures
|
||||
f := s.Failures
|
||||
b.WriteString(section("Failures") + "\n")
|
||||
out = append(out, section("Failures"))
|
||||
put(line(fmt.Sprintf(" inf %d · t/o %d · tool %d · rej %d · stg %d · wf %d",
|
||||
f.InferenceFailures, f.InferenceTimeouts, f.ToolFailures, f.ToolRejections, f.StageFailures, f.WorkflowFailures)))
|
||||
b.WriteString("\n")
|
||||
blank()
|
||||
|
||||
// time accounting
|
||||
other := 100.0 - s.InferencePct - s.ToolPct - s.ApprovalWaitPct
|
||||
if other < 0 {
|
||||
other = 0
|
||||
}
|
||||
b.WriteString(section("Time") + "\n")
|
||||
out = append(out, section("Time"))
|
||||
put(line(fmt.Sprintf(" inf %.1f%% · tools %.1f%% · wait %.1f%% · other %.1f%%",
|
||||
s.InferencePct, s.ToolPct, s.ApprovalWaitPct, other)))
|
||||
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"S/esc", "close"}}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
return out
|
||||
}
|
||||
|
||||
func (m Model) healthModal() string {
|
||||
@@ -820,7 +1283,7 @@ func shortID(id string) string {
|
||||
return id[:13] + "…"
|
||||
}
|
||||
|
||||
func tierColor(t Theme, tier int) lipgloss.Color {
|
||||
func tierColor(t Theme, tier int) color.Color {
|
||||
switch {
|
||||
case tier >= 3:
|
||||
return t.P.Bad
|
||||
|
||||
Reference in New Issue
Block a user