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
+61
View File
@@ -167,6 +167,10 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.overlayEventIdx = 0
case "t":
m.overlay = OverlayToolPalette
case "v":
m.openArtifacts()
case "g":
m.openConfig()
case "m":
m.openModelsOverlay()
case "w":
@@ -340,6 +344,10 @@ func (m Model) autoApprove() (tea.Model, tea.Cmd) {
}
func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
// Config overlay owns all its keys (esc cancels an in-progress edit rather than closing).
if m.overlay == OverlayConfig {
return m.handleConfigKey(k)
}
if k.Type == tea.KeyEsc {
m.overlay = OverlayNone
return m, nil
@@ -376,6 +384,29 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
if runeIs(k, "t") {
m.overlay = OverlayNone
}
case OverlayArtifacts:
switch {
case k.Type == tea.KeyUp || runeIs(k, "k"):
if m.artifactsIndex > 0 {
m.artifactsIndex--
m.artifactScroll = 0
}
case k.Type == tea.KeyDown || runeIs(k, "j"):
if m.artifactsIndex < len(m.artifacts)-1 {
m.artifactsIndex++
m.artifactScroll = 0
}
case k.Type == tea.KeyPgUp:
if m.artifactScroll > 0 {
m.artifactScroll--
}
case k.Type == tea.KeyPgDown:
if m.artifactScroll < m.artifactContentMaxScroll() {
m.artifactScroll++
}
case runeIs(k, "v"):
m.overlay = OverlayNone
}
case OverlayModels:
switch {
case k.Type == tea.KeyUp || runeIs(k, "k"):
@@ -401,6 +432,23 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
// openArtifacts opens the artifact viewer for the selected session and requests its
// artifacts from the server. No-op when no session is selected.
func (m *Model) openArtifacts() {
if m.selectedID == "" {
return
}
m.overlay = OverlayArtifacts
m.artifactsIndex = 0
m.artifactScroll = 0
// Reuse a cached listing only if it's for this session; otherwise show a loading state.
if m.artifactsFor != m.selectedID {
m.artifacts = nil
m.artifactsLoading = true
}
m.client.Send(protocol.ListArtifacts(m.selectedID))
}
// openModelsOverlay opens the model picker, pre-selecting the resident model.
func (m *Model) openModelsOverlay() {
m.overlay = OverlayModels
@@ -452,6 +500,8 @@ func paletteCommands() []paletteCmd {
{"tools", "tool palette", "tools for the current stage"},
{"models", "swap model", "pick / pin the local model"},
{"events", "event inspector", "browse the event stream"},
{"artifacts", "view artifacts", "browse this session's artifacts"},
{"config", "edit config", "view / change correx settings"},
{"mode", "toggle mode", "switch chat / steering"},
{"cancel", "cancel session", "stop the selected session"},
{"back", "back to list", "leave the current session"},
@@ -486,6 +536,10 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
case "events":
m.overlay = OverlayEventInspector
m.overlayEventIdx = 0
case "artifacts":
m.openArtifacts()
case "config":
m.openConfig()
case "mode":
m.cycleChatMode()
case "cancel":
@@ -683,6 +737,13 @@ func (m *Model) listNav(dir int) {
break
}
}
// No current selection (e.g. nothing entered yet): start at the first
// session rather than wrapping off the end into an arbitrary entry.
if idx < 0 {
m.selectedID = list[0].ID
m.wfIndex = -1
return
}
n := idx + dir
if n < 0 {
n = len(list) - 1