feat(tui,server,config): live config editor + artifact viewer
Two operator features over the existing WebSocket protocol, plus a tui-go nav fix. Config editor (g / palette "config"): - core:config gains a thin registry stack: OrchestrationKnobs ([orchestration] block), CorrexConfigWriter (round-trip TOML serializer; parseToml(write(c))==c), ConfigHolder (AtomicReference live config), and EditableConfig (allowlist of editable fields + patch applier; security fields are absent so they can never be patched). - ConfigService validates -> persists (atomic temp+move) -> swaps the holder -> rebuilds config-derived services. Live-apply is scoped to safe seams: stage timeout, compaction threshold (now a supplier), router knobs (routerFacade rebuild), and personalization/project toggles all take effect on the next session/turn; in-flight sessions keep the config they started with. server.host/port are persisted + badged "restart required", not hot-swapped. - Protocol: GetConfig / UpdateConfig / ConfigSnapshot; TUI overlay with type-aware editing (bool toggle, enum cycle, inline int/string edit) and save. Artifact viewer (v / palette "artifacts"): - Server assembles a session's artifacts from the event log and resolves bytes from the CAS store (StreamQueries.listArtifacts) — a replay-neutral snapshot read. TUI overlay lists artifacts and shows scrollable content. tui-go fix: workflow failure no longer clears selectedID (which yanked the user to the list); listNav lands on the first session when nothing is selected instead of wrapping to a random one. Config is not event-sourced (it lives in TOML); editing stays outside the log.
This commit is contained in:
@@ -39,6 +39,10 @@ func (m Model) renderOverlay(base string) string {
|
||||
return m.center(m.toolPaletteModal())
|
||||
case OverlayModels:
|
||||
return m.center(m.modelsModal())
|
||||
case OverlayArtifacts:
|
||||
return m.center(m.artifactsModal())
|
||||
case OverlayConfig:
|
||||
return m.center(m.configModal())
|
||||
}
|
||||
return base
|
||||
}
|
||||
@@ -414,6 +418,137 @@ func (m Model) modelsModal() string {
|
||||
return m.center(modal)
|
||||
}
|
||||
|
||||
// artifactListRows is the number of artifact entries shown in the list portion of
|
||||
// the viewer (the rest of the modal height goes to the selected artifact's content).
|
||||
func (m Model) artifactListRows() int {
|
||||
n := len(m.artifacts)
|
||||
if n > 6 {
|
||||
n = 6
|
||||
}
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// artifactContentBodyH is the number of content lines visible for the selected artifact.
|
||||
func (m Model) artifactContentBodyH() int {
|
||||
h := m.height*70/100 - m.artifactListRows() - 7 // title, blanks, content header, hints
|
||||
if h < 3 {
|
||||
h = 3
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// artifactContentLines splits the selected artifact's content into display lines.
|
||||
func (m Model) artifactContentLines() []string {
|
||||
if m.artifactsIndex < 0 || m.artifactsIndex >= len(m.artifacts) {
|
||||
return nil
|
||||
}
|
||||
c := m.artifacts[m.artifactsIndex].Content
|
||||
if c == nil {
|
||||
return []string{"(no content stored)"}
|
||||
}
|
||||
return strings.Split(strings.ReplaceAll(*c, "\r\n", "\n"), "\n")
|
||||
}
|
||||
|
||||
// artifactContentMaxScroll keeps the last page of content anchored to the body bottom.
|
||||
func (m Model) artifactContentMaxScroll() int {
|
||||
max := len(m.artifactContentLines()) - m.artifactContentBodyH()
|
||||
if max < 0 {
|
||||
max = 0
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
func (m Model) artifactsModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("artifacts"))
|
||||
if len(m.artifacts) > 0 {
|
||||
b.WriteString(mbg(t, " ("+itoa(m.artifactsIndex+1)+"/"+itoa(len(m.artifacts))+")", t.P.Faint))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if m.artifactsLoading {
|
||||
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"esc", "close"}}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
if len(m.artifacts) == 0 {
|
||||
b.WriteString(mbg(t, " no artifacts for this session", t.P.Faint) + "\n")
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"esc", "close"}}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
|
||||
// Windowed artifact list, keeping the selected row in view.
|
||||
rows := m.artifactListRows()
|
||||
off := 0
|
||||
if len(m.artifacts) > rows {
|
||||
off = m.artifactsIndex - rows/2
|
||||
if off < 0 {
|
||||
off = 0
|
||||
}
|
||||
if off > len(m.artifacts)-rows {
|
||||
off = len(m.artifacts) - rows
|
||||
}
|
||||
}
|
||||
end := off + rows
|
||||
if end > len(m.artifacts) {
|
||||
end = len(m.artifacts)
|
||||
}
|
||||
for i := off; i < end; i++ {
|
||||
a := m.artifacts[i]
|
||||
marker := mbg(t, " ", t.P.BgPanel)
|
||||
idFg := t.P.Fg
|
||||
if i == m.artifactsIndex {
|
||||
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ")
|
||||
idFg = t.P.FgStrong
|
||||
}
|
||||
row := marker +
|
||||
lipgloss.NewStyle().Foreground(idFg).Background(t.P.BgPanel).Render(padRaw(shortID(a.ArtifactID), 14)) + " " +
|
||||
mbg(t, padRaw(a.StageID, 18), t.P.Accent2) + " " +
|
||||
mbg(t, a.Phase, t.P.Faint)
|
||||
b.WriteString(row + "\n")
|
||||
}
|
||||
|
||||
// Selected artifact content, scrollable with PgUp/PgDn.
|
||||
lines := m.artifactContentLines()
|
||||
bodyH := m.artifactContentBodyH()
|
||||
coff := m.artifactScroll
|
||||
if coff > m.artifactContentMaxScroll() {
|
||||
coff = m.artifactContentMaxScroll()
|
||||
}
|
||||
if coff < 0 {
|
||||
coff = 0
|
||||
}
|
||||
b.WriteString(mbg(t, " ── content", t.P.Faint))
|
||||
if len(lines) > bodyH {
|
||||
b.WriteString(mbg(t, " ("+itoa(coff+1)+"-"+itoa(min(coff+bodyH, len(lines)))+"/"+itoa(len(lines))+")", t.P.Faint))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
cend := coff + bodyH
|
||||
if cend > len(lines) {
|
||||
cend = len(lines)
|
||||
}
|
||||
for i := coff; i < cend; i++ {
|
||||
b.WriteString(mbg(t, " "+truncate(lines[i], w-6), t.P.Fg) + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"PgUp/Dn", "scroll"}, {"v/esc", "close"}}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
|
||||
// shortID trims a long artifact id for the list column while staying identifiable.
|
||||
func shortID(id string) string {
|
||||
if len(id) <= 14 {
|
||||
return id
|
||||
}
|
||||
return id[:13] + "…"
|
||||
}
|
||||
|
||||
func tierColor(t Theme, tier int) lipgloss.Color {
|
||||
switch {
|
||||
case tier >= 3:
|
||||
|
||||
Reference in New Issue
Block a user