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:
2026-06-09 10:18:35 +04:00
parent 89487db72a
commit a92b5a3531
27 changed files with 1629 additions and 72 deletions
+135
View File
@@ -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: