package app import ( "crypto/rand" "encoding/hex" "fmt" "strings" "time" "github.com/correx/tui-go/internal/protocol" ) func nowMillis() int64 { return time.Now().UnixMilli() } func newSessionID() string { b := make([]byte, 16) _, _ = rand.Read(b) return hex.EncodeToString(b) } func containsFold(haystack, needle string) bool { return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle)) } // formatTime renders an event/activity instant as a local wall-clock time, matching the // operator's clock (and the local date shown in the session list). It was previously forced // to UTC, so every event timestamp read hours off the user's actual time. func formatTime(epochMillis int64) string { return time.UnixMilli(epochMillis).Format("15:04:05") } // inferCategory maps an event type string to a display category, matching the // Kotlin inferCategory heuristic. func inferCategory(t string) string { lt := strings.ToLower(t) switch { case strings.Contains(lt, "approval"): return "Approval" case strings.Contains(lt, "infer"): return "Inference" case strings.Contains(lt, "tool"): return "Tool" case strings.Contains(lt, "context"): return "Context" case strings.Contains(lt, "stage"), strings.Contains(lt, "lifecycle"): return "Lifecycle" case strings.Contains(lt, "session"), strings.Contains(lt, "artifact"): return "Domain" default: return "Lifecycle" } } // narrationLine maps a progress frame to a dim router-feed line, or "" if the // frame is not a narration trigger. func narrationLine(msg protocol.ServerMessage) string { switch msg.Type { case protocol.TypeStageStarted: return "→ " + msg.StageID case protocol.TypeStageCompleted: return "✓ " + msg.StageID case protocol.TypeStageFailed: if msg.Reason != "" { return "⚠ " + msg.StageID + ": " + msg.Reason } return "⚠ " + msg.StageID case protocol.TypeSessionPaused: return "⏸ paused" case protocol.TypeSessionResumed: return "▶ resumed" case protocol.TypeSessionCompleted: return "✓ workflow complete" case protocol.TypeSessionFailed: if msg.Reason != "" { return "✗ workflow failed: " + msg.Reason } return "✗ workflow failed" default: return "" } } // applyServer mutates the model for a single (non-buffered) server message. func (m *Model) applyServer(msg protocol.ServerMessage) { debugLog("SRV type=%s session=%s reason=%s", msg.Type, msg.SessionID, msg.Reason) // Auto-vivify: any event for an unknown session creates it, so session // existence is derived from the event stream, not a dedicated control frame. if msg.IsEventBearing() && msg.SessionID != "" { m.ensureSession(msg.SessionID) } // Inject dim narration lines for progress frames. Runs in addition to the // per-type handling below (which feeds the events panel separately). if line := narrationLine(msg); line != "" { m.appendRouter(msg.SessionID, RouterEntry{Role: "narration", Content: line}) } switch msg.Type { case protocol.TypeSessionAnnounced: m.onSessionAnnounced(msg) case protocol.TypeRouterNarration: entry := RouterEntry{Role: "narration_llm", Content: msg.Content} if msg.LatencyMs != nil && msg.TotalTokens != nil { entry.Metrics = &TurnMetrics{LatencyMs: *msg.LatencyMs, TotalTokens: *msg.TotalTokens} } m.appendRouter(msg.SessionID, entry) case protocol.TypeChatTurn: m.routerConnected = true role := "router" if msg.Role == "USER" { role = "user" } entry := RouterEntry{Role: role, Content: msg.Content} if msg.LatencyMs != nil && msg.TotalTokens != nil { entry.Metrics = &TurnMetrics{LatencyMs: *msg.LatencyMs, TotalTokens: *msg.TotalTokens} } m.appendRouter(msg.SessionID, entry) if s := m.session(msg.SessionID); s != nil { s.LastEventAt = nowMillis() } case protocol.TypeSessionPaused: label := "PAUSED" if msg.Reason == "APPROVAL_PENDING" { label = "PAUSED awaiting approval" } m.touch(msg.SessionID, label) if s := m.session(msg.SessionID); s != nil { s.Active = false } case protocol.TypeSessionResumed: if s := m.session(msg.SessionID); s != nil { s.Status = "ACTIVE" s.clearApprovals() s.LastEventAt = nowMillis() } case protocol.TypeSessionCompleted: m.touch(msg.SessionID, "COMPLETED") if s := m.session(msg.SessionID); s != nil { s.Active = false s.Clar = nil s.Propose = nil s.clearApprovals() } case protocol.TypeSessionFailed: m.touch(msg.SessionID, "FAILED") if s := m.session(msg.SessionID); s != nil { s.Active = false s.Clar = nil s.Propose = nil s.clearApprovals() } case protocol.TypeStageStarted: if s := m.session(msg.SessionID); s != nil { s.CurrentStage = msg.StageID s.Tools = nil s.StageTokensUsed = 0 s.addEvent(msg.OccurredAt, "StageStarted", msg.StageID) for i := range s.PlanStages { if s.PlanStages[i].ID == msg.StageID { s.PlanStages[i].Status = PlanRunning break } } } case protocol.TypeStageCompleted: if s := m.session(msg.SessionID); s != nil { s.CurrentStage = "" s.addEvent(msg.OccurredAt, "StageCompleted", msg.StageID) for i := range s.PlanStages { if s.PlanStages[i].ID == msg.StageID { s.PlanStages[i].Status = PlanCompleted break } } } case protocol.TypeStageFailed: if s := m.session(msg.SessionID); s != nil { s.CurrentStage = "" s.addEvent(msg.OccurredAt, "StageFailed", msg.StageID) for i := range s.PlanStages { if s.PlanStages[i].ID == msg.StageID { s.PlanStages[i].Status = PlanFailed break } } } case protocol.TypeInferenceStarted: if s := m.session(msg.SessionID); s != nil { s.Active = true s.addEvent(nowMillis(), "InferenceStarted", msg.StageID) } case protocol.TypeInferenceDone: if s := m.session(msg.SessionID); s != nil { s.Active = false s.LastOutput = msg.Summary if msg.Response != "" { s.LastResponse = msg.Response } if msg.TotalTokens != nil { s.StageTokensUsed = *msg.TotalTokens } s.addEvent(msg.OccurredAt, "InferenceCompleted", msg.StageID) } case protocol.TypeInferenceTimeout: if s := m.session(msg.SessionID); s != nil { s.Active = false s.addEvent(nowMillis(), "InferenceTimedOut", msg.StageID) } case protocol.TypeInferenceFailed: if s := m.session(msg.SessionID); s != nil { s.Active = false s.addEvent(nowMillis(), "InferenceFailed", msg.StageID+": "+msg.Reason) } case protocol.TypeInferenceRetry: if s := m.session(msg.SessionID); s != nil { s.addEvent(nowMillis(), "RetryAttempted", fmt.Sprintf("%s (%d/%d): %s", msg.StageID, msg.AttemptNumber, msg.MaxAttempts, msg.FailureReason)) } case protocol.TypeToolStarted: if s := m.session(msg.SessionID); s != nil { s.Active = true s.Tools = append(s.Tools, ToolRecord{Name: msg.ToolName, Status: ToolStarted}) if len(s.Tools) > 8 { s.Tools = s.Tools[len(s.Tools)-8:] } s.LastEventAt = nowMillis() } // Tool-start is not surfaced inline (it doubles up with the ✓/✎ result row); it // stays in the tool list + EVENTS panel. case protocol.TypeToolCompleted: if s := m.session(msg.SessionID); s != nil { s.Active = false s.markTool(msg.ToolName, ToolCompleted) s.LastOutput = msg.ToolName + ": " + msg.Summary s.addEvent(msg.OccurredAt, "ToolCompleted", msg.ToolName) } if msg.Diff != nil && *msg.Diff != "" { // A diff = a write/edit: name the file + the line delta, then keep the // existing collapsed diff row (^x opens the full diff). path, add, del := diffSummary(*msg.Diff) m.appendAction(msg.SessionID, "✎", "wrote "+path+countSuffix(add, del)) m.appendRouter(msg.SessionID, RouterEntry{Role: "tool", Content: *msg.Diff}) } else { label := msg.ToolName if len(msg.AffectedEntities) > 0 { label += " " + strings.Join(msg.AffectedEntities, ", ") } m.appendAction(msg.SessionID, "✓", actionToolText(label, msg.Summary)) } case protocol.TypeReviewFindings: if s := m.session(msg.SessionID); s != nil { findings := make([]ReviewFinding, 0, len(msg.ReviewFindings)) for _, f := range msg.ReviewFindings { fix := "" if f.SuggestedFix != nil { fix = *f.SuggestedFix } findings = append(findings, ReviewFinding{ Severity: f.Severity, Confidence: f.Confidence, Category: f.Category, Target: f.Target, Message: f.Message, SuggestedFix: fix, Correctness: f.Correctness, }) } s.LastReview = &ReviewResult{ StageID: msg.StageID, Verdict: msg.ReviewVerdict, Findings: findings, Blocked: msg.ReviewBlocked, } detail := msg.ReviewVerdict if len(findings) > 0 { detail += " (" + plural(len(findings), "finding") + ")" } if msg.ReviewBlocked { detail += " · blocked" } s.LastOutput = "review: " + detail s.addEvent(nowMillis(), "ReviewFindings", msg.StageID+": "+detail) icon := "✓" if msg.ReviewVerdict == "FAIL" { icon = "✕" } else if msg.ReviewVerdict == "WARN" { icon = "▲" } m.appendAction(msg.SessionID, icon, "review "+detail) } case protocol.TypeToolAssessed: if s := m.session(msg.SessionID); s != nil { if msg.Disposition != "PROCEED" || len(msg.AssessedIssues) > 0 { detail := msg.Disposition if len(msg.AssessedIssues) > 0 { detail += " [" + msg.AssessedIssues[0].Code + "]" } s.LastOutput = msg.ToolName + " assessed: " + detail s.addEvent(msg.OccurredAt, "ToolAssessed", detail) } } case protocol.TypeToolFailed: if s := m.session(msg.SessionID); s != nil { s.markTool(msg.ToolName, ToolFailed) s.LastEventAt = msg.OccurredAt } m.appendAction(msg.SessionID, "✗", actionToolText(msg.ToolName+" failed", msg.Reason)) case protocol.TypeToolRejected: if s := m.session(msg.SessionID); s != nil { s.markTool(msg.ToolName, ToolRejected) s.LastEventAt = nowMillis() } m.appendAction(msg.SessionID, "✕", actionToolText(msg.ToolName+" blocked", msg.Reason)) case protocol.TypeArtifactCreated: if s := m.session(msg.SessionID); s != nil { s.addEvent(nowMillis(), "ArtifactCreated", msg.ArtifactID) } case protocol.TypeArtifactValid: if s := m.session(msg.SessionID); s != nil { s.addEvent(nowMillis(), "ArtifactValidated", msg.ArtifactID) } case protocol.TypePlanLocked: if s := m.session(msg.SessionID); s != nil { s.addEvent(nowMillis(), "PlanLocked", strings.Join(msg.StageIDs, " → ")) s.LastOutput = "plan locked: " + strings.Join(msg.StageIDs, " → ") s.PlanGoal = msg.Content s.PlanStages = make([]PlanStage, 0, len(msg.StageIDs)) for _, id := range msg.StageIDs { s.PlanStages = append(s.PlanStages, PlanStage{ID: id, Status: PlanPending}) } } case protocol.TypeWorkspaceBound: if s := m.session(msg.SessionID); s != nil { s.WorkspaceRoot = msg.WorkspaceRoot } case protocol.TypeApprovalRequired: m.onApprovalRequired(msg) case protocol.TypeClarification: m.onClarificationRequired(msg) case protocol.TypeWorkflowProposed: m.onWorkflowProposed(msg) case protocol.TypeApprovalResolved: if s := m.session(msg.SessionID); s != nil { // The resolved gate names no tool on the wire; recover it from the pending // queue before dropping the gate, for the inline row. tool := "" for _, a := range s.PendingQueue { if a.RequestID == msg.RequestID { tool = a.ToolName break } } // Drop just the resolved gate; any others stay queued and the band // advances to the next rather than vanishing entirely. s.removeApproval(msg.RequestID) detail := msg.Outcome if msg.Reason != "" { detail += " — " + msg.Reason } s.addEvent(msg.OccurredAt, "ApprovalResolved", detail) s.LastEventAt = nowMillis() m.appendAction(msg.SessionID, approvalIcon(msg.Outcome), approvalActionText(msg.Outcome, tool, msg.Reason)) } case protocol.TypeSessionSnapshot: m.onSnapshot(msg) case protocol.TypeStageManifest: if s := m.session(msg.SessionID); s != nil { byStage := map[string][]ManifestTool{} budgetByStage := map[string]int{} for _, st := range msg.Stages { tools := make([]ManifestTool, 0, len(st.Tools)) for _, td := range st.Tools { tools = append(tools, ManifestTool{Name: td.Name, Tier: td.Tier}) } byStage[st.StageID] = tools if st.TokenBudget != nil { budgetByStage[st.StageID] = *st.TokenBudget } } s.ToolsByStage = byStage s.TokenBudgetByStage = budgetByStage } case protocol.TypeProjectProfile: errMsg := "" if msg.ConfigError != nil { errMsg = *msg.ConfigError } m.applyProjectProfileSnapshot(msg.About, msg.Conventions, msg.Commands, errMsg) case protocol.TypeOperatorProfile: errMsg := "" if msg.ConfigError != nil { errMsg = *msg.ConfigError } proposed := "" if msg.ProposedAdaptation != nil { proposed = *msg.ProposedAdaptation } m.applyOperatorProfileSnapshot(msg.About, msg.ApprovalMode, msg.PreferredModels, msg.Conventions, proposed, errMsg) case protocol.TypeProviderStatus: m.currentModel = msg.ProviderID if containsFold(msg.ProviderID, "llama") || containsFold(msg.ProviderID, "local") { m.providerType = "LOCAL" } else { m.providerType = "REMOTE" } case protocol.TypeWorkflowList: m.workflows = m.workflows[:0] for _, w := range msg.Workflows { m.workflows = append(m.workflows, Workflow{ID: w.WorkflowID, Description: w.Description}) } case protocol.TypeModelChanged: if msg.Loaded { m.currentModel = msg.ModelID m.providerType = "LOCAL" } case protocol.TypeModelList: m.availableModels = append(m.availableModels[:0], msg.Models...) if msg.Current != "" { m.currentModel = msg.Current m.providerType = "LOCAL" } case protocol.TypeResourceStatus: m.gpuUsedMB = msg.GpuMemoryUsedMb m.gpuTotalMB = msg.GpuMemoryTotalMb m.gpuUtil = msg.GpuUtilizationPct m.ramMB = msg.ProcessRssMb m.sysRamUsedMB = msg.SystemRamUsedMb m.sysRamTotalMB = msg.SystemRamTotalMb case protocol.TypeArtifactList: m.artifacts = msg.Artifacts m.artifactsFor = msg.SessionID m.artifactsLoading = false m.artifactScroll = 0 if m.artifactsIndex >= len(m.artifacts) { m.artifactsIndex = 0 } case protocol.TypeSessionStats: m.stats = msg.Stats m.statsFor = msg.SessionID m.statsLoading = false case protocol.TypeIdeaList: m.ideas = msg.Ideas m.ideasLoading = false if m.ideasIndex >= len(m.ideas) { m.ideasIndex = 0 } case protocol.TypeHealthChecks: m.health = msg.Health m.healthLoading = false case protocol.TypeFileList: m.files = msg.Paths m.filesFor = msg.SessionID m.filesLoading = false if m.fileIndex >= len(m.filteredFiles()) { m.fileIndex = 0 } case protocol.TypeGrantList: m.grants = msg.Grants m.grantsLoading = false if m.grantIndex >= len(m.grants) { m.grantIndex = 0 } case protocol.TypeConfigSnapshot: m.configFields = msg.ConfigFields m.configRestart = msg.ConfigRestartRequired m.configLoading = false if msg.ConfigError != nil { m.configError = *msg.ConfigError } else { // A clean snapshot means the staged edits were accepted (or this is a fresh fetch). m.configError = "" m.configStaged = map[string]string{} } if m.configIndex >= len(m.configFields) { m.configIndex = 0 } case protocol.TypeRouterResponse, protocol.TypeProtocolError: // Recognized but intentionally not rendered here: router.response is // superseded by chat.turn events on the global stream, and protocol_error is // a transport diagnostic. Explicit no-op so the default below means *unknown*. default: // Unknown-event raw fallback (TUI-requirements §2): a session-scoped event // type this client doesn't recognize is surfaced as a raw row in the event // stream, never silently dropped — the frontend must not lie by omission. if msg.SessionID != "" { s := m.ensureSession(msg.SessionID) at := msg.OccurredAt if at == 0 { at = nowMillis() } s.addEvent(at, msg.Type, "(raw — unrendered event)") } } // Background-update badge for non-selected sessions. if sid := sessionIDOf(msg); sid != "" && sid != m.selectedID { m.bgUpdates++ } } func sessionIDOf(msg protocol.ServerMessage) string { switch msg.Type { case protocol.TypeStageManifest, protocol.TypeSnapshotComplete, protocol.TypeProtocolError, protocol.TypeProviderStatus, protocol.TypeWorkflowList, protocol.TypeRouterResponse, protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus, protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats, protocol.TypeIdeaList, protocol.TypeHealthChecks, protocol.TypeFileList, protocol.TypeGrantList, protocol.TypeOperatorProfile: return "" default: return msg.SessionID } } // --- inline action-row helpers (external-feedback events surfaced in OUTPUT) --- // diffSummary extracts the written path and +/- line counts from a unified diff for the // inline write row. Falls back to "file" when no `+++` header is present. func diffSummary(diff string) (path string, added, removed int) { path = "file" for _, ln := range strings.Split(diff, "\n") { switch { case strings.HasPrefix(ln, "+++ "): p := strings.TrimSpace(strings.TrimPrefix(ln, "+++ ")) p = strings.TrimPrefix(p, "b/") if p != "" && p != "/dev/null" { path = p } case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++ "): added++ case strings.HasPrefix(ln, "-") && !strings.HasPrefix(ln, "--- "): removed++ } } return path, added, removed } // countSuffix renders the " (+a −b)" delta, or "" when nothing changed. func countSuffix(added, removed int) string { if added == 0 && removed == 0 { return "" } return " (+" + itoa(added) + " −" + itoa(removed) + ")" } // actionToolText joins a tool label with a short, clipped result summary. func actionToolText(label, summary string) string { // Collapse to a single line first: tool summaries (dir listings, file heads) // often carry newlines, and a multi-line action row paints a background stripe // per line with the raw content leaking underneath. s := strings.Join(strings.Fields(summary), " ") if s == "" { return label } return label + " · " + clip(s, 48) } func approvalIcon(outcome string) string { if outcome == "REJECTED" { return "✕" } return "⌘" } // approvalActionText renders the inline approval row, noting an auto-approval that fired via a // standing grant (reason "grant:"). func approvalActionText(outcome, tool, reason string) string { verb := "approved" switch outcome { case "REJECTED": verb = "rejected" case "AUTO_APPROVED": verb = "auto-approved" } txt := verb if tool != "" { txt = verb + " " + tool } if strings.HasPrefix(reason, "grant:") { txt += " · via grant" } return txt } // onSessionAnnounced fills in a session's workflow identity (the announce is the // only event carrying workflowId) and applies auto-focus. The session entry itself // was already created by the auto-vivify path in applyServer. func (m *Model) onSessionAnnounced(msg protocol.ServerMessage) { s := m.ensureSession(msg.SessionID) s.Status = "ACTIVE" if msg.WorkflowID != "" { s.WorkflowID = msg.WorkflowID s.Name = msg.WorkflowID } s.LastEventAt = nowMillis() if m.pendingWorkflowFocus { m.selectedID = msg.SessionID m.sessionEntered = true m.pendingWorkflowFocus = false } else if m.selectedID == "" { m.selectedID = msg.SessionID } } func (m *Model) onApprovalRequired(msg protocol.ServerMessage) { risk := "unknown" var rationale []string if msg.RiskSummary != nil { risk = msg.RiskSummary.Level rationale = msg.RiskSummary.Rationale } info := &Approval{ RequestID: msg.RequestID, SessionID: msg.SessionID, Tier: msg.Tier, Risk: risk, ToolName: deref(&msg.ToolName), Preview: derefp(msg.Preview), Rationale: rationale, } if s := m.session(msg.SessionID); s != nil { s.enqueueApproval(info) } } func (m *Model) onClarificationRequired(msg protocol.ServerMessage) { qs := make([]ClarQuestion, 0, len(msg.Questions)) for _, q := range msg.Questions { qs = append(qs, ClarQuestion{ ID: q.ID, Prompt: q.Prompt, Header: q.Header, Options: q.Options, MultiSelect: q.MultiSelect, }) } c := &Clarification{ RequestID: msg.RequestID, SessionID: msg.SessionID, StageID: msg.StageID, Questions: qs, } if s := m.session(msg.SessionID); s != nil { s.Clar = c } // A newly-arrived clarification must always surface. clarDismissed is model-global, and // clarInitState (which clears it) only runs for the selected session — so a clarification // arriving for an unselected session while the flag was left set would stay silently hidden. m.clarDismissed = false if msg.SessionID == m.selectedID { m.clarInitState(len(qs)) } } func (m *Model) onWorkflowProposed(msg protocol.ServerMessage) { cands := make([]ProposeCandidate, 0, len(msg.Candidates)) for _, c := range msg.Candidates { cands = append(cands, ProposeCandidate{WorkflowID: c.WorkflowID, Reason: c.Reason}) } p := &Proposal{ ProposalID: msg.ProposalID, SessionID: msg.SessionID, Prompt: msg.Prompt, OriginalRequest: msg.OriginalRequest, Candidates: cands, } if s := m.session(msg.SessionID); s != nil { s.Propose = p } if msg.SessionID == m.selectedID { m.proposeInitState() } } func (m *Model) onSnapshot(msg protocol.ServerMessage) { var queue []*Approval for _, a := range msg.PendingAppr { queue = append(queue, &Approval{ RequestID: a.RequestID, SessionID: msg.SessionID, Tier: a.Tier, Risk: "unknown", ToolName: derefp(a.ToolName), Preview: derefp(a.Preview), }) } status := "running" if msg.State != nil { status = msg.State.Status } if len(queue) > 0 { status = "PAUSED awaiting approval" } sess := Session{ ID: msg.SessionID, Status: status, WorkflowID: msg.WorkflowID, Name: msg.WorkflowID, LastEventAt: nowMillis(), PendingQueue: queue, } sess.syncPending() if msg.State != nil && msg.State.CurrentStageID != nil { sess.CurrentStage = *msg.State.CurrentStageID } sess.LastOutput = msg.LastOutput sess.LastResponse = msg.LastResponse sess.WorkspaceRoot = msg.WorkspaceRoot for _, e := range msg.RecentEvents { sess.Events = append(sess.Events, EventEntry{formatTime(e.Timestamp), e.Type, e.Detail}) } for _, t := range msg.Tools { sess.Tools = append(sess.Tools, ToolRecord{Name: t.Name, Tier: t.Tier, Status: toolStatusOf(t.Status)}) } // Replace existing session with same id, else append. replaced := false for i := range m.sessions { if m.sessions[i].ID == msg.SessionID { m.sessions[i] = sess replaced = true break } } if !replaced { m.sessions = append(m.sessions, sess) } if m.selectedID == "" { m.selectedID = msg.SessionID } } func (m *Model) touch(id, status string) { if s := m.session(id); s != nil { s.Status = status s.LastEventAt = nowMillis() } } // --- Session helpers --- // maxSessionEvents bounds retained per-session history (effectively "all" for a // session, while keeping memory bounded on very long runs). The EVENTS panel // shows the tail that fits; the `e` inspector scrolls the full retained list. const maxSessionEvents = 1000 func (s *Session) addEvent(epochMillis int64, typ, detail string) { s.Events = append(s.Events, EventEntry{formatTime(epochMillis), typ, detail}) if len(s.Events) > maxSessionEvents { s.Events = s.Events[len(s.Events)-maxSessionEvents:] } s.LastEventAt = epochMillis } func (s *Session) markTool(name string, status ToolStatus) { for i := range s.Tools { if s.Tools[i].Name == name && s.Tools[i].Status == ToolStarted { s.Tools[i].Status = status } } } func toolStatusOf(s string) ToolStatus { switch s { case "COMPLETED": return ToolCompleted case "FAILED": return ToolFailed case "REJECTED": return ToolRejected default: return ToolStarted } } func deref(s *string) string { if s == nil { return "" } return *s } func derefp(s *string) string { return deref(s) }