diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..62aa76c2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# Normalize line endings. Go tooling (gofmt, go vet) treats CRLF as +# unformatted, so everything textual is stored and checked out as LF +# regardless of the contributor's platform. +* text=auto eol=lf + +# Binary assets β€” never touch line endings. +*.png binary +*.gif binary +*.jpg binary +*.jpeg binary +*.webm binary +*.mp4 binary +*.ico binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..b571f324 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + # Match the toolchain pinned in go.work. + go-version: "1.26.x" + cache-dependency-path: | + **/go.sum + + - name: gofmt + run: | + unformatted="$(gofmt -l $(git ls-files '*.go'))" + if [ -n "$unformatted" ]; then + echo "gofmt needs to run on:" >&2 + echo "$unformatted" >&2 + exit 1 + fi + + # The repo root is a go.work workspace, not a module, so + # `go ./...` from the root doesn't span the members. + # Run vet / build / test inside each workspace module instead; + # the module list is read from go.work so it stays in sync as + # modules are added or removed. + - name: Build, vet, and test each workspace module + run: | + set -e + for m in $(grep -oE '\./[^ ]+' go.work); do + echo "::group::$m" + ( cd "$m" && go vet ./... && go build ./... && go test ./... -count=1 ) + echo "::endgroup::" + done diff --git a/adapters/claudecode/server.go b/adapters/claudecode/server.go index c9af76f3..57c91cb3 100644 --- a/adapters/claudecode/server.go +++ b/adapters/claudecode/server.go @@ -67,8 +67,8 @@ type sessionState struct { // to send back in the control_response and a chan to wake up the // goroutine that's blocking on the user's decision. type pendingPerm struct { - id string // GACT permission id (perm_xxx) - requestID string // claude's control_request request_id + id string // GACT permission id (perm_xxx) + requestID string // claude's control_request request_id sessionID string record map[string]any // PermissionRequest dict for GET /v1/permissions resp chan permResp @@ -666,11 +666,11 @@ func (s *Server) captureCatalogs(initEv map[string]any) { gactStatus = "disconnected" } s.mcpServers = append(s.mcpServers, map[string]any{ - "id": slugify(name), - "name": name, - "transport": "stdio", - "status": gactStatus, - "x_claudecode_raw_status": rawStatus, + "id": slugify(name), + "name": name, + "transport": "stdio", + "status": gactStatus, + "x_claudecode_raw_status": rawStatus, }) } } diff --git a/adapters/claudecode/subprocess.go b/adapters/claudecode/subprocess.go index 0167a974..cc2ef3f4 100644 --- a/adapters/claudecode/subprocess.go +++ b/adapters/claudecode/subprocess.go @@ -193,4 +193,3 @@ func (cp *claudeProcess) close() { _ = cp.stdin.Close() _ = cp.cmd.Wait() } - diff --git a/adapters/crush/messages.go b/adapters/crush/messages.go index ec777317..3bc9d814 100644 --- a/adapters/crush/messages.go +++ b/adapters/crush/messages.go @@ -175,9 +175,9 @@ func translatePart(w crushPartWrapper, index int) (gact.Part, string, error) { } return gact.Part{ ID: id, Type: gact.PartTypeToolResult, - CallID: d.ToolCallID, - IsError: d.IsError, - Content: []gact.Part{{Type: gact.PartTypeText, Text: d.Content}}, + CallID: d.ToolCallID, + IsError: d.IsError, + Content: []gact.Part{{Type: gact.PartTypeText, Text: d.Content}}, Metadata: meta, }, "", nil diff --git a/adapters/crush/translate.go b/adapters/crush/translate.go index e15cc66a..55419ca1 100644 --- a/adapters/crush/translate.go +++ b/adapters/crush/translate.go @@ -34,17 +34,17 @@ type CrushWorkspace struct { // CrushSession mirrors crush proto.Session. type CrushSession struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id,omitempty"` - ParentSessionID string `json:"parent_session_id,omitempty"` - Title string `json:"title,omitempty"` - MessageCount int `json:"message_count,omitempty"` - PromptTokens int `json:"prompt_tokens,omitempty"` - CompletionTokens int `json:"completion_tokens,omitempty"` - Cost float64 `json:"cost,omitempty"` - SummaryMessageID string `json:"summary_message_id,omitempty"` - CreatedAt int64 `json:"created_at,omitempty"` - UpdatedAt int64 `json:"updated_at,omitempty"` + ID string `json:"id"` + WorkspaceID string `json:"workspace_id,omitempty"` + ParentSessionID string `json:"parent_session_id,omitempty"` + Title string `json:"title,omitempty"` + MessageCount int `json:"message_count,omitempty"` + PromptTokens int `json:"prompt_tokens,omitempty"` + CompletionTokens int `json:"completion_tokens,omitempty"` + Cost float64 `json:"cost,omitempty"` + SummaryMessageID string `json:"summary_message_id,omitempty"` + CreatedAt int64 `json:"created_at,omitempty"` + UpdatedAt int64 `json:"updated_at,omitempty"` } // WorkspaceToGact maps Crush workspace β†’ GACT workspace. Crush carries diff --git a/adapters/crush/translate_test.go b/adapters/crush/translate_test.go index 0a628461..55ee7d5d 100644 --- a/adapters/crush/translate_test.go +++ b/adapters/crush/translate_test.go @@ -43,7 +43,7 @@ func TestSessionToGact(t *testing.T) { in := CrushSession{ ID: "ses_1", Title: "fix it", PromptTokens: 1500, CompletionTokens: 600, Cost: 0.0135, SummaryMessageID: "msg_summary", - CreatedAt: 1700000000, + CreatedAt: 1700000000, } g := SessionToGact(in, "ws_default") if g.WorkspaceID != "ws_default" { diff --git a/adapters/goose/server.go b/adapters/goose/server.go index 4390e605..979b56af 100644 --- a/adapters/goose/server.go +++ b/adapters/goose/server.go @@ -537,9 +537,9 @@ func (s *Server) runUpstreamReply(sid, text string) { // Mark session running. s.broadcast(sid, eventEnvelope("session.status_changed", map[string]any{ - "session_id": sid, - "status": gact.StatusRunning, - "prev_status": gact.StatusIdle, + "session_id": sid, + "status": gact.StatusRunning, + "prev_status": gact.StatusIdle, })) resp, err := s.client.Post(s.upstream+"/reply", diff --git a/adapters/goose/translate.go b/adapters/goose/translate.go index f2230e10..947c34b2 100644 --- a/adapters/goose/translate.go +++ b/adapters/goose/translate.go @@ -170,11 +170,11 @@ func languageFor(path string) string { // counts, recipe, extension data) are left out so JSON decode is // tolerant to additions on the upstream side. type gooseSession struct { - ID string `json:"id"` - Name string `json:"name"` - WorkingDir string `json:"working_dir"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + WorkingDir string `json:"working_dir"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // MMMMMMM1: conversation is included on per-id session reads (it's // Option upstream, where Conversation is a newtype // over Vec). When absent we just expose an empty @@ -186,10 +186,10 @@ type gooseSession struct { // the fields the bridge needs are decoded; metadata + everything else // flows through opaquely. type gooseMessage struct { - ID *string `json:"id,omitempty"` - Role string `json:"role"` - Created int64 `json:"created"` - Content []map[string]any `json:"content"` + ID *string `json:"id,omitempty"` + Role string `json:"role"` + Created int64 `json:"created"` + Content []map[string]any `json:"content"` } // gooseSessionList is the shape returned by Goose's GET /sessions. @@ -247,6 +247,7 @@ func roleToGact(r string) string { // handles both shapes: // - {"text": {"text": "..."}} (untagged) // - {"type": "text", "text": "..."} (internally tagged) +// // Unknown variants serialise as a text placeholder per the SPEC Β§5.4 // forward-compat rule. func contentToGactPart(raw map[string]any) gact.Part { @@ -352,9 +353,9 @@ func toolRespToGact(raw map[string]any) gact.Part { contentParts = append(contentParts, gact.NewTextPart("[empty tool response]")) } return gact.Part{ - Type: gact.PartTypeToolResult, - CallID: id, - Content: contentParts, + Type: gact.PartTypeToolResult, + CallID: id, + Content: contentParts, } } diff --git a/adapters/opencode/translate.go b/adapters/opencode/translate.go index 3f976949..cc40f996 100644 --- a/adapters/opencode/translate.go +++ b/adapters/opencode/translate.go @@ -130,17 +130,17 @@ func WorkspaceFromProject(p OcProjectInfo) gact.Workspace { // packages/opencode/src/session/message-v2.ts. The TS shape is // discriminated by `role`. We carry only the fields we translate. type OcMessage struct { - ID string `json:"id"` - SessionID string `json:"sessionID"` - Role string `json:"role"` // "user" | "assistant" - Time OcTimes `json:"time"` - ParentID string `json:"parentID,omitempty"` - ProviderID string `json:"providerID,omitempty"` - ModelID string `json:"modelID,omitempty"` - Agent string `json:"agent,omitempty"` - Cost float64 `json:"cost,omitempty"` + ID string `json:"id"` + SessionID string `json:"sessionID"` + Role string `json:"role"` // "user" | "assistant" + Time OcTimes `json:"time"` + ParentID string `json:"parentID,omitempty"` + ProviderID string `json:"providerID,omitempty"` + ModelID string `json:"modelID,omitempty"` + Agent string `json:"agent,omitempty"` + Cost float64 `json:"cost,omitempty"` Tokens OcTokens `json:"tokens,omitempty"` - Finish string `json:"finish,omitempty"` + Finish string `json:"finish,omitempty"` } // OcTokens mirrors OpenCode's tokens sub-object on assistant messages. diff --git a/adapters/opencode/translate_test.go b/adapters/opencode/translate_test.go index 42106785..a7c7ad26 100644 --- a/adapters/opencode/translate_test.go +++ b/adapters/opencode/translate_test.go @@ -114,10 +114,10 @@ func TestWorkspaceFromProject_DefaultsName(t *testing.T) { func TestSanitizeID(t *testing.T) { cases := map[string]string{ - "abc123": "abc123", - "a_b-c": "a_b-c", + "abc123": "abc123", + "a_b-c": "a_b-c", "with/slash": "withslash", - "emojiπŸŽ‰": "emoji", + "emojiπŸŽ‰": "emoji", } for in, want := range cases { if got := sanitizeID(in); got != want { diff --git a/contract/conformance/reporter.go b/contract/conformance/reporter.go index 443b40bf..73168e93 100644 --- a/contract/conformance/reporter.go +++ b/contract/conformance/reporter.go @@ -39,10 +39,10 @@ func FromTest(t *testing.T) Reporter { type testTReporter struct{ t *testing.T } -func (r *testTReporter) Helper() { r.t.Helper() } -func (r *testTReporter) Errorf(format string, args ...any) { r.t.Errorf(format, args...) } -func (r *testTReporter) Fatal(args ...any) { r.t.Fatal(args...) } -func (r *testTReporter) Fatalf(format string, args ...any) { r.t.Fatalf(format, args...) } +func (r *testTReporter) Helper() { r.t.Helper() } +func (r *testTReporter) Errorf(format string, args ...any) { r.t.Errorf(format, args...) } +func (r *testTReporter) Fatal(args ...any) { r.t.Fatal(args...) } +func (r *testTReporter) Fatalf(format string, args ...any) { r.t.Fatalf(format, args...) } func (r *testTReporter) Run(name string, fn func(Reporter)) { r.t.Run(name, func(child *testing.T) { fn(&testTReporter{t: child}) diff --git a/emulator/cmd/emulator-server/e2e_test.go b/emulator/cmd/emulator-server/e2e_test.go index 7856ca47..f0d6c556 100644 --- a/emulator/cmd/emulator-server/e2e_test.go +++ b/emulator/cmd/emulator-server/e2e_test.go @@ -139,13 +139,13 @@ func TestE2E_FullScenarioFlow(t *testing.T) { // Read events until message.completed (then the wrap-up status_changed). wantSeen := map[string]bool{ - "message.created": false, - "message.part.added": false, - "message.part.delta": false, - "message.part.completed": false, - "tool.call.started": false, - "tool.call.completed": false, - "message.completed": false, + "message.created": false, + "message.part.added": false, + "message.part.delta": false, + "message.part.completed": false, + "tool.call.started": false, + "tool.call.completed": false, + "message.completed": false, } deadline := time.After(8 * time.Second) doneEvents := 0 diff --git a/emulator/internal/scenario/diff_script.go b/emulator/internal/scenario/diff_script.go index c5138e60..809c0180 100644 --- a/emulator/internal/scenario/diff_script.go +++ b/emulator/internal/scenario/diff_script.go @@ -7,9 +7,10 @@ import ( ) // runDiffScript demonstrates the file_diff part flow: -// parent assistant turn proposes a code change as a file_diff part with -// before/after content. The TUI renders the diff inline and lets the -// user apply or reject via /v1/sessions/{id}/diffs/{apply,reject}. +// +// parent assistant turn proposes a code change as a file_diff part with +// before/after content. The TUI renders the diff inline and lets the +// user apply or reject via /v1/sessions/{id}/diffs/{apply,reject}. // // Triggered by "diff" / "edit" / "patch" in the user's message. // @@ -60,23 +61,23 @@ var diffVariants = []struct { after string }{ { - intro: "Here's the change. Press **a** to apply or **r** to reject from the conversation pane.", - path: "main.go", - lang: "go", + intro: "Here's the change. Press **a** to apply or **r** to reject from the conversation pane.", + path: "main.go", + lang: "go", before: "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n", after: "package main\n\nimport \"log\"\n\nfunc main() {\n\tlog.Println(\"hello, world\")\n}\n", }, { - intro: "Wrap the network call in a try/except so a transient DNS blip doesn't kill the worker. **a**=apply, **r**=reject.", - path: "worker/fetch.py", - lang: "python", + intro: "Wrap the network call in a try/except so a transient DNS blip doesn't kill the worker. **a**=apply, **r**=reject.", + path: "worker/fetch.py", + lang: "python", before: "import requests\n\n\ndef fetch_user(uid: str) -> dict:\n r = requests.get(f\"https://api.example.com/users/{uid}\", timeout=5)\n return r.json()\n", after: "import logging\nimport requests\n\nlog = logging.getLogger(__name__)\n\n\ndef fetch_user(uid: str) -> dict:\n try:\n r = requests.get(f\"https://api.example.com/users/{uid}\", timeout=5)\n r.raise_for_status()\n return r.json()\n except requests.RequestException as exc:\n log.warning(\"fetch_user(%s) failed: %s\", uid, exc)\n return {}\n", }, { - intro: "Swap the callback chain for async/await β€” same semantics, an order of magnitude less indentation. **a**=apply, **r**=reject.", - path: "src/loader.js", - lang: "javascript", + intro: "Swap the callback chain for async/await β€” same semantics, an order of magnitude less indentation. **a**=apply, **r**=reject.", + path: "src/loader.js", + lang: "javascript", before: "function loadUser(id, cb) {\n db.get('users', id, function (err, row) {\n if (err) return cb(err);\n cache.set(id, row, function (err2) {\n if (err2) return cb(err2);\n cb(null, row);\n });\n });\n}\n", after: "async function loadUser(id) {\n const row = await db.get('users', id);\n await cache.set(id, row);\n return row;\n}\n", }, diff --git a/emulator/internal/scenario/rich_scripts.go b/emulator/internal/scenario/rich_scripts.go index aebbceb7..5e3f812e 100644 --- a/emulator/internal/scenario/rich_scripts.go +++ b/emulator/internal/scenario/rich_scripts.go @@ -573,11 +573,11 @@ type multiToolStep struct { // empty the script skips emitting it β€” variants 1 + 2 that don't // centre on an edit keep their old shape. var multiToolVariants = []struct { - intro string - tools []multiToolStep - followup string - diffPath string - diffLang string + intro string + tools []multiToolStep + followup string + diffPath string + diffLang string diffBefore string diffAfter string }{ diff --git a/emulator/internal/scenario/routing_script.go b/emulator/internal/scenario/routing_script.go index 64c07c49..ee368f28 100644 --- a/emulator/internal/scenario/routing_script.go +++ b/emulator/internal/scenario/routing_script.go @@ -14,14 +14,14 @@ import ( // // What it emits (in order): // -// 1. session.status_changed β†’ running -// 2. routing_decision part as the FIRST part of the assistant -// message β€” selected_agent matched by keyword against the -// catalog's tier-2 entries (code_expert / research_expert / -// data_expert). heuristic = true, confidence = 0.85. -// 3. session.agent_routed event carrying selected_agent + rationale. -// 4. A text part answering with the picked agent's voice. -// 5. message.completed. +// 1. session.status_changed β†’ running +// 2. routing_decision part as the FIRST part of the assistant +// message β€” selected_agent matched by keyword against the +// catalog's tier-2 entries (code_expert / research_expert / +// data_expert). heuristic = true, confidence = 0.85. +// 3. session.agent_routed event carrying selected_agent + rationale. +// 4. A text part answering with the picked agent's voice. +// 5. message.completed. // // This is the end-to-end path a TUI consumer exercises for badge // rendering + routing rationale display. Pairs with diff --git a/emulator/internal/scenario/routing_script_test.go b/emulator/internal/scenario/routing_script_test.go index d69c2158..5a1092ab 100644 --- a/emulator/internal/scenario/routing_script_test.go +++ b/emulator/internal/scenario/routing_script_test.go @@ -77,8 +77,8 @@ func TestRoutingScript_EmitsRoutingDecisionPart(t *testing.T) { // distinct keyword hints. func TestRoutingScript_PicksByKeyword(t *testing.T) { cases := []struct { - prompt string - wantAgent string + prompt string + wantAgent string }{ {"please refactor this function", "code_expert"}, {"can you search the web for pandas", "research_expert"}, diff --git a/emulator/internal/scenario/scenario.go b/emulator/internal/scenario/scenario.go index 15158e97..2aec58c6 100644 --- a/emulator/internal/scenario/scenario.go +++ b/emulator/internal/scenario/scenario.go @@ -188,8 +188,8 @@ func (e *Engine) publishStatus(sessionID, status string) { WorkspaceID: updated.WorkspaceID, SessionID: sessionID, Payload: map[string]any{ - "session_id": sessionID, - "status": status, + "session_id": sessionID, + "status": status, "prev_status": prev, }, }) diff --git a/emulator/internal/scenario/scenario_test.go b/emulator/internal/scenario/scenario_test.go index d2576dad..7052af09 100644 --- a/emulator/internal/scenario/scenario_test.go +++ b/emulator/internal/scenario/scenario_test.go @@ -254,7 +254,6 @@ loop: } } - func mustContain(t *testing.T, slice []string, want string) { t.Helper() for _, s := range slice { diff --git a/emulator/internal/server/handlers_catalog.go b/emulator/internal/server/handlers_catalog.go index 0a12cd4f..5ef1fbd5 100644 --- a/emulator/internal/server/handlers_catalog.go +++ b/emulator/internal/server/handlers_catalog.go @@ -123,7 +123,7 @@ func staticTools() []gact.Tool { ID: "read_file", Source: "builtin", Name: "read_file", Title: "Read file", Description: "Read the contents of a file.", InputSchema: map[string]any{ - "type": "object", + "type": "object", "properties": map[string]any{"path": stringSchema()}, "required": []string{"path"}, }, @@ -148,7 +148,7 @@ func staticTools() []gact.Tool { ID: "web_search", Source: "builtin", Name: "web_search", Title: "Search the web", Description: "Search the web for relevant pages.", InputSchema: map[string]any{ - "type": "object", + "type": "object", "properties": map[string]any{"query": stringSchema()}, "required": []string{"query"}, }, @@ -159,7 +159,7 @@ func staticTools() []gact.Tool { ID: "fake-mcp.fetch", Source: "mcp", ServerID: "mcp_fake", Name: "fetch", Title: "Fetch URL", Description: "(MCP) Download a URL and return its contents.", InputSchema: map[string]any{ - "type": "object", + "type": "object", "properties": map[string]any{"url": stringSchema()}, "required": []string{"url"}, }, @@ -264,28 +264,28 @@ func staticAgents() []gact.AgentDef { // specific. { ID: "code_expert", Source: "builtin", Title: "Code Expert", - Description: "Source-level editing, review, refactoring.", - DefaultModel: &gact.ModelRef{ProviderID: "anthropic", ModelID: "claude-sonnet-4-6"}, - Tools: []string{"read_file", "edit_file", "grep"}, - Tier: 2, + Description: "Source-level editing, review, refactoring.", + DefaultModel: &gact.ModelRef{ProviderID: "anthropic", ModelID: "claude-sonnet-4-6"}, + Tools: []string{"read_file", "edit_file", "grep"}, + Tier: 2, Specialization: "code_editing", Keywords: []string{"edit", "refactor", "fix", "review", "patch"}, }, { ID: "research_expert", Source: "builtin", Title: "Research Expert", - Description: "Web search + document retrieval + synthesis.", - DefaultModel: &gact.ModelRef{ProviderID: "anthropic", ModelID: "claude-sonnet-4-6"}, - Tools: []string{"web_search", "read_file"}, - Tier: 2, + Description: "Web search + document retrieval + synthesis.", + DefaultModel: &gact.ModelRef{ProviderID: "anthropic", ModelID: "claude-sonnet-4-6"}, + Tools: []string{"web_search", "read_file"}, + Tier: 2, Specialization: "knowledge_retrieval", Keywords: []string{"search", "find", "look up", "research", "citations"}, }, { ID: "data_expert", Source: "builtin", Title: "Data Expert", - Description: "Profile and analyse structured data files.", - DefaultModel: &gact.ModelRef{ProviderID: "anthropic", ModelID: "claude-sonnet-4-6"}, - Tools: []string{"read_file", "bash"}, - Tier: 2, + Description: "Profile and analyse structured data files.", + DefaultModel: &gact.ModelRef{ProviderID: "anthropic", ModelID: "claude-sonnet-4-6"}, + Tools: []string{"read_file", "bash"}, + Tier: 2, Specialization: "data_analysis", Keywords: []string{"analyze", "profile", "inspect", "data", "csv", "parquet"}, }, @@ -450,7 +450,7 @@ func staticMcpServers() []gact.McpServer { { ID: "mcp_fake", Name: "fake-mcp", Version: "0.1.0", Transport: "stdio", ProtocolVersion: "2025-06-18", Status: "ready", - ServerInfo: map[string]any{"name": "fake-mcp", "version": "0.1.0"}, + ServerInfo: map[string]any{"name": "fake-mcp", "version": "0.1.0"}, Instructions: "Demo MCP server. Two tools (fetch, dbquery), one resource, one prompt.", DeclaredCapabilities: gact.McpCapabilities{ Tools: true, @@ -1137,7 +1137,7 @@ func (s *Server) handleSessionUndo(w http.ResponseWriter, r *http.Request) { func collectDiffs(s *Server, sessionID, onlyMsgID string) []gact.FileDiff { out := []gact.FileDiff{} - walkDiffParts(s, sessionID, onlyMsgID, func(_ , _ string, p *gact.Part) { + walkDiffParts(s, sessionID, onlyMsgID, func(_, _ string, p *gact.Part) { out = append(out, gact.FileDiff{ Path: p.Path, Before: p.Before, diff --git a/emulator/internal/server/handlers_sessions.go b/emulator/internal/server/handlers_sessions.go index c6638d1c..82de8385 100644 --- a/emulator/internal/server/handlers_sessions.go +++ b/emulator/internal/server/handlers_sessions.go @@ -12,13 +12,13 @@ import ( // CreateSessionRequest is the body for POST /v1/sessions (SPEC Β§6.2). type CreateSessionRequest struct { - WorkspaceID string `json:"workspace_id"` - Title string `json:"title,omitempty"` - Agent *gact.AgentRef `json:"agent,omitempty"` - Model *gact.ModelRef `json:"model,omitempty"` - ParentSessionID string `json:"parent_session_id,omitempty"` - ForkAtMessageID string `json:"fork_at_message_id,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` + WorkspaceID string `json:"workspace_id"` + Title string `json:"title,omitempty"` + Agent *gact.AgentRef `json:"agent,omitempty"` + Model *gact.ModelRef `json:"model,omitempty"` + ParentSessionID string `json:"parent_session_id,omitempty"` + ForkAtMessageID string `json:"fork_at_message_id,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` } // UpdateSessionRequest is the body for PATCH /v1/sessions/{id}. @@ -53,7 +53,7 @@ type ListSessionsResponse struct { // /v1/sessions/import. Carries the session and all its messages, with a // format tag so future versions can migrate. type SessionExport struct { - Format string `json:"format"` // "gact-v1" + Format string `json:"format"` // "gact-v1" ExportedAt time.Time `json:"exported_at"` Session gact.Session `json:"session"` Messages []gact.Message `json:"messages"` // chronological (oldest-first) diff --git a/emulator/internal/server/latency.go b/emulator/internal/server/latency.go index 872d5b23..24af4872 100644 --- a/emulator/internal/server/latency.go +++ b/emulator/internal/server/latency.go @@ -64,10 +64,10 @@ func (l *latencyTracker) Record(pattern string, d time.Duration) { // Snapshot returns a copy of the current per-route stats. Safe to call // concurrently with Record. type latencyStat struct { - Count int `json:"count"` - P50Ms float64 `json:"p50_ms"` - P95Ms float64 `json:"p95_ms"` - MaxMs float64 `json:"max_ms"` + Count int `json:"count"` + P50Ms float64 `json:"p50_ms"` + P95Ms float64 `json:"p95_ms"` + MaxMs float64 `json:"max_ms"` } func (l *latencyTracker) Snapshot() map[string]latencyStat { diff --git a/emulator/internal/store/permissions.go b/emulator/internal/store/permissions.go index b964d11f..e9d1e526 100644 --- a/emulator/internal/store/permissions.go +++ b/emulator/internal/store/permissions.go @@ -205,7 +205,7 @@ func (p *Permissions) Get(id string) (*PermissionRequest, bool) { // PermissionFilter narrows a list query. type PermissionFilter struct { - SessionID string // empty = all sessions + SessionID string // empty = all sessions OnlyPending bool } diff --git a/emulator/pkg/gact/catalog.go b/emulator/pkg/gact/catalog.go index 97092622..7d715be4 100644 --- a/emulator/pkg/gact/catalog.go +++ b/emulator/pkg/gact/catalog.go @@ -80,22 +80,22 @@ func (a AuthProvider) NeedsLogin() bool { // Model is one LLM offered by a provider (SPEC Β§6.12). type Model struct { - ID string `json:"id"` - Name string `json:"name"` - ContextWindow int `json:"context_window"` - MaxOutputTokens int `json:"max_output_tokens"` - Supports ModelSupports `json:"supports"` - Pricing *ModelPricing `json:"pricing,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + ContextWindow int `json:"context_window"` + MaxOutputTokens int `json:"max_output_tokens"` + Supports ModelSupports `json:"supports"` + Pricing *ModelPricing `json:"pricing,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` } // ModelSupports captures capability flags for a model. type ModelSupports struct { - Tools bool `json:"tools"` - Vision bool `json:"vision"` - Thinking bool `json:"thinking"` - ComputerUse bool `json:"computer_use"` - PromptCaching bool `json:"prompt_caching"` + Tools bool `json:"tools"` + Vision bool `json:"vision"` + Thinking bool `json:"thinking"` + ComputerUse bool `json:"computer_use"` + PromptCaching bool `json:"prompt_caching"` } // ModelPricing is per-million-token pricing. @@ -128,15 +128,15 @@ type Tool struct { // tier-2 specialists so the TUI can render a routing badge and // colour it by specialization. type AgentDef struct { - ID string `json:"id"` - Source string `json:"source"` // builtin|user|recipe|skill - Title string `json:"title"` - Description string `json:"description,omitempty"` - SystemPrompt string `json:"system_prompt,omitempty"` - Parameters []AgentParameter `json:"parameters,omitempty"` - DefaultModel *ModelRef `json:"default_model,omitempty"` - Tools []string `json:"tools,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` + ID string `json:"id"` + Source string `json:"source"` // builtin|user|recipe|skill + Title string `json:"title"` + Description string `json:"description,omitempty"` + SystemPrompt string `json:"system_prompt,omitempty"` + Parameters []AgentParameter `json:"parameters,omitempty"` + DefaultModel *ModelRef `json:"default_model,omitempty"` + Tools []string `json:"tools,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` // v0.2 β€” multi-tier routing (optional; absent = tier-1 or untagged) Tier int `json:"tier,omitempty"` // 1 = orchestrator, 2 = specialist, 3 = nanoagent @@ -155,24 +155,24 @@ type AgentParameter struct { // McpServer is one connected MCP server (SPEC Β§6.7). type McpServer struct { - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version,omitempty"` - Transport string `json:"transport"` // "stdio" | "http" - ProtocolVersion string `json:"protocol_version"` - Status string `json:"status"` // connecting|ready|error|disconnected - ServerInfo map[string]any `json:"server_info,omitempty"` - Instructions string `json:"instructions,omitempty"` - DeclaredCapabilities McpCapabilities `json:"declared_capabilities"` - LastError string `json:"last_error,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version,omitempty"` + Transport string `json:"transport"` // "stdio" | "http" + ProtocolVersion string `json:"protocol_version"` + Status string `json:"status"` // connecting|ready|error|disconnected + ServerInfo map[string]any `json:"server_info,omitempty"` + Instructions string `json:"instructions,omitempty"` + DeclaredCapabilities McpCapabilities `json:"declared_capabilities"` + LastError string `json:"last_error,omitempty"` } // McpCapabilities describes which MCP capabilities a server declares. type McpCapabilities struct { - Tools bool `json:"tools"` + Tools bool `json:"tools"` Resources *McpResourcesCapability `json:"resources,omitempty"` Prompts *McpPromptsCapability `json:"prompts,omitempty"` - Logging bool `json:"logging"` + Logging bool `json:"logging"` } type McpResourcesCapability struct { @@ -215,11 +215,11 @@ type McpContent struct { // McpPrompt is a templated prompt exposed by a server (SPEC Β§6.7). type McpPrompt struct { - ServerID string `json:"server_id"` - Name string `json:"name"` - Title string `json:"title,omitempty"` - Description string `json:"description,omitempty"` - Arguments []McpPromptArg `json:"arguments,omitempty"` + ServerID string `json:"server_id"` + Name string `json:"name"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Arguments []McpPromptArg `json:"arguments,omitempty"` } type McpPromptArg struct { @@ -272,22 +272,22 @@ type FileDiff struct { // Command is one slash command available in the catalog (SPEC Β§6.13). type Command struct { - ID string `json:"id"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Source string `json:"source"` // builtin|mcp_prompt|recipe|user - ServerID string `json:"server_id,omitempty"` - Arguments []AgentParameter `json:"arguments,omitempty"` - Shortcut string `json:"shortcut,omitempty"` + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Source string `json:"source"` // builtin|mcp_prompt|recipe|user + ServerID string `json:"server_id,omitempty"` + Arguments []AgentParameter `json:"arguments,omitempty"` + Shortcut string `json:"shortcut,omitempty"` } // Metrics is the body of GET /v1/metrics (SPEC Β§6.16). type Metrics struct { - UptimeS int `json:"uptime_s"` - Sessions MetricsSessions `json:"sessions"` - Messages MetricsMessages `json:"messages"` - Tokens MetricsTokens `json:"tokens"` - Cost MetricsCost `json:"cost"` + UptimeS int `json:"uptime_s"` + Sessions MetricsSessions `json:"sessions"` + Messages MetricsMessages `json:"messages"` + Tokens MetricsTokens `json:"tokens"` + Cost MetricsCost `json:"cost"` Latencies map[string]MetricsLatencyStat `json:"latencies,omitempty"` } diff --git a/emulator/pkg/gact/messaging.go b/emulator/pkg/gact/messaging.go index d4566a12..feb7f238 100644 --- a/emulator/pkg/gact/messaging.go +++ b/emulator/pkg/gact/messaging.go @@ -155,11 +155,11 @@ type Message struct { // Recoverable hints whether a retry could succeed (true) or whether // user/operator intervention is required (false). type ErrorInfo struct { - Error string `json:"error"` - Message string `json:"message"` - Details map[string]any `json:"details,omitempty"` - Recoverable bool `json:"recoverable"` - RetryAfterS *int `json:"retry_after_s,omitempty"` + Error string `json:"error"` + Message string `json:"message"` + Details map[string]any `json:"details,omitempty"` + Recoverable bool `json:"recoverable"` + RetryAfterS *int `json:"retry_after_s,omitempty"` } // Part is a single content block within a Message (SPEC Β§4.5). @@ -316,12 +316,12 @@ const ( // PermissionRequest is the wire-level shape of a pending permission request // (SPEC Β§4.7). The server's store wraps this with status + resolution. type PermissionRequest struct { - ID string `json:"id"` - SessionID string `json:"session_id"` - SubsessionID string `json:"subsession_id,omitempty"` - ToolCall PermissionToolCall `json:"tool_call"` - Summary string `json:"summary,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + SessionID string `json:"session_id"` + SubsessionID string `json:"subsession_id,omitempty"` + ToolCall PermissionToolCall `json:"tool_call"` + Summary string `json:"summary,omitempty"` + CreatedAt time.Time `json:"created_at"` } // PermissionToolCall is the subset of a tool call that needs user approval. diff --git a/emulator/pkg/gact/types.go b/emulator/pkg/gact/types.go index 64a7bcf2..11886868 100644 --- a/emulator/pkg/gact/types.go +++ b/emulator/pkg/gact/types.go @@ -43,11 +43,11 @@ type CacheStats struct { } type SessionMemoryStats struct { - SessionID string `json:"session_id"` - MessagesRetained int `json:"messages_retained"` - TokensRetained int `json:"tokens_retained"` - TokensBudget *int `json:"tokens_budget,omitempty"` // null = unbounded - ProfilesAttached int `json:"profiles_attached"` // opaque to the TUI + SessionID string `json:"session_id"` + MessagesRetained int `json:"messages_retained"` + TokensRetained int `json:"tokens_retained"` + TokensBudget *int `json:"tokens_budget,omitempty"` // null = unbounded + ProfilesAttached int `json:"profiles_attached"` // opaque to the TUI } type GlobalMemoryStats struct { @@ -100,11 +100,11 @@ type CapabilityFlags struct { SkillsExtraction bool `json:"skills_extraction"` // v0.2 additions β€” SPEC Β§3.2.1 - AgentRouting bool `json:"agent_routing"` // multi-tier agents + routing_decision part + session.agent_routed event - Memory bool `json:"memory"` // /v1/memory/stats endpoint (Β§6.19) - StructuredErrors bool `json:"structured_errors"` // Β§14 typed error_info taxonomy - IntegrationHealth bool `json:"integration_health"` // /v1/health integrations[] + overall_status - ToolTelemetry bool `json:"tool_telemetry"` // tool_result.cached + duration_ms + AgentRouting bool `json:"agent_routing"` // multi-tier agents + routing_decision part + session.agent_routed event + Memory bool `json:"memory"` // /v1/memory/stats endpoint (Β§6.19) + StructuredErrors bool `json:"structured_errors"` // Β§14 typed error_info taxonomy + IntegrationHealth bool `json:"integration_health"` // /v1/health integrations[] + overall_status + ToolTelemetry bool `json:"tool_telemetry"` // tool_result.cached + duration_ms } type TransportFlags struct { @@ -129,9 +129,9 @@ type Extension struct { // patterns: `*` matches any chars except `/`, `**` matches across // path segments. (MMM4) type Policy struct { - Scope string `json:"scope"` // "workspace" | "session" + Scope string `json:"scope"` // "workspace" | "session" ScopeID string `json:"scope_id,omitempty"` // empty = any scope - ToolNamePattern string `json:"tool_name_pattern"` // e.g. "shell" or "*" + ToolNamePattern string `json:"tool_name_pattern"` // e.g. "shell" or "*" PathPattern string `json:"path_pattern,omitempty"` Action string `json:"action"` // "allow" | "deny" | "ask" AnnotationsFilter map[string]any `json:"annotations_filter,omitempty"` diff --git a/tui/internal/client/client.go b/tui/internal/client/client.go index 76cb0343..f7055680 100644 --- a/tui/internal/client/client.go +++ b/tui/internal/client/client.go @@ -277,9 +277,9 @@ func (c *Client) PostMessage(ctx context.Context, sessionID string, req PostMess // SearchMatch mirrors SPEC Β§6.3 β€” one hit from /messages/search. type SearchMatch struct { - MessageID string `json:"message_id"` - PartID string `json:"part_id"` - Snippet string `json:"snippet"` + MessageID string `json:"message_id"` + PartID string `json:"part_id"` + Snippet string `json:"snippet"` Score float64 `json:"score"` } @@ -332,9 +332,9 @@ func (c *Client) ListTools(ctx context.Context) ([]gact.Tool, error) { // internally; the TUI sees this shape over the wire. type PermissionWire struct { gact.PermissionRequest - Status string `json:"status"` - Action gact.PermissionAction `json:"action,omitempty"` - ResolvedAt time.Time `json:"resolved_at,omitempty"` + Status string `json:"status"` + Action gact.PermissionAction `json:"action,omitempty"` + ResolvedAt time.Time `json:"resolved_at,omitempty"` } func (c *Client) ListPermissions(ctx context.Context, sessionID string, onlyPending bool) ([]PermissionWire, error) { @@ -367,16 +367,16 @@ func (c *Client) ListProviders(ctx context.Context) ([]gact.Provider, error) { return out.Providers, err } -// LMProviderPreset is a row in CLIO's provider picker. ``RequiresAPIKey`` +// LMProviderPreset is a row in CLIO's provider picker. β€œRequiresAPIKeyβ€œ // tells the TUI's modal whether to render the api_key field. type LMProviderPreset struct { - ID string `json:"id"` - Label string `json:"label"` - Provider string `json:"provider"` - APIBase string `json:"api_base"` - SuggestedModel string `json:"suggested_model"` - RequiresAPIKey bool `json:"requires_api_key"` - Description string `json:"description"` + ID string `json:"id"` + Label string `json:"label"` + Provider string `json:"provider"` + APIBase string `json:"api_base"` + SuggestedModel string `json:"suggested_model"` + RequiresAPIKey bool `json:"requires_api_key"` + Description string `json:"description"` } // LMProviderInfo is the GET /v1/providers/lm body β€” current LM @@ -524,12 +524,12 @@ func (c *Client) RunCommand(ctx context.Context, sessionID, cmdID string) error // PatchSessionRequest mirrors server.UpdateSessionRequest fields the TUI // needs (avoids importing server internals into the client). type PatchSessionRequest struct { - Title *string `json:"title,omitempty"` - Archived *bool `json:"archived,omitempty"` - Agent *gact.AgentRef `json:"agent,omitempty"` - Model *gact.ModelRef `json:"model,omitempty"` - Status *string `json:"status,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` + Title *string `json:"title,omitempty"` + Archived *bool `json:"archived,omitempty"` + Agent *gact.AgentRef `json:"agent,omitempty"` + Model *gact.ModelRef `json:"model,omitempty"` + Status *string `json:"status,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` // RoutingMode toggles the agent's routing override per session. // "auto" = LM-based router; "chat" = force chat path (no /chat // prefix needed); "experts" = reject chat/none routes. diff --git a/tui/internal/config/agents.go b/tui/internal/config/agents.go index 0316d9e8..2b5471f0 100644 --- a/tui/internal/config/agents.go +++ b/tui/internal/config/agents.go @@ -17,12 +17,12 @@ import ( // machine. (OOOOOOOOO1) type AgentRecord struct { Name string `json:"name"` - Kind string `json:"kind"` // "claudecode", "opencode", "crush", "goose", ... - Bin string `json:"bin"` // path to the adapter binary - Host string `json:"host"` // usually 127.0.0.1 - Port int `json:"port"` // tcp port the adapter listens on - PID int `json:"pid"` // OS pid of the adapter process (0 if unknown) - Cwd string `json:"cwd"` // working directory passed at spawn time + Kind string `json:"kind"` // "claudecode", "opencode", "crush", "goose", ... + Bin string `json:"bin"` // path to the adapter binary + Host string `json:"host"` // usually 127.0.0.1 + Port int `json:"port"` // tcp port the adapter listens on + PID int `json:"pid"` // OS pid of the adapter process (0 if unknown) + Cwd string `json:"cwd"` // working directory passed at spawn time StartedAt time.Time `json:"started_at"` // LogPath is where the spawn's stdout/stderr were redirected at // deploy time. Empty for adapters spawned before the log-redirect diff --git a/tui/internal/config/config_test.go b/tui/internal/config/config_test.go index 533ac9b5..946e9a61 100644 --- a/tui/internal/config/config_test.go +++ b/tui/internal/config/config_test.go @@ -107,12 +107,12 @@ func TestResolvePrecedence(t *testing.T) { // flag wins over env wins over file wins over fallback. cases := []struct { - name string - file *string - env string - flag string - fb string - want string + name string + file *string + env string + flag string + fb string + want string }{ {"all unset", nil, "", "", "default", "default"}, {"flag only", nil, "", "set-flag", "default", "set-flag"}, diff --git a/tui/internal/config/detached_test.go b/tui/internal/config/detached_test.go index 6727fd7c..42fc51be 100644 --- a/tui/internal/config/detached_test.go +++ b/tui/internal/config/detached_test.go @@ -84,8 +84,8 @@ func TestDetached_AppendTrimsToMaxRecords(t *testing.T) { path := filepath.Join(dir, "detached.json") for i := 0; i < 10; i++ { if err := AppendDetached(path, DetachedRecord{ - SessionID: "sess_" + string(rune('0'+i)), - Backend: "http://b", + SessionID: "sess_" + string(rune('0'+i)), + Backend: "http://b", DetachedAt: time.Now().Add(time.Duration(i) * time.Second).UTC(), }, 5); err != nil { t.Fatal(err) diff --git a/tui/internal/ui/app.go b/tui/internal/ui/app.go index 940cbbca..0fbd9bf7 100644 --- a/tui/internal/ui/app.go +++ b/tui/internal/ui/app.go @@ -98,7 +98,7 @@ type App struct { stageError string focus FocusZone - caps gact.Capabilities + caps gact.Capabilities // CLIO-BBBBBBBBBB4 (v0.2 Β§6.19): last-known memory stats from // the backend. Populated when capabilities.memory = true and // the client fetches GET /v1/memory/stats on session-status @@ -106,10 +106,10 @@ type App struct { // Zero-value renders as nothing (no chip). memoryStats gact.MemoryStats workspaces []gact.Workspace - wsID string - sessions []gact.Session - selected int // index into sessions; -1 if none - commands []gact.Command + wsID string + sessions []gact.Session + selected int // index into sessions; -1 if none + commands []gact.Command // Loaded messages for the currently selected session. messages []gact.Message @@ -360,7 +360,7 @@ type App struct { // Cached LM provider info (set on every lmConfigFetchedMsg). Powers // the header model chip (#363) so we don't need a per-render fetch. lmProviderInfo *client.LMProviderInfo - doctor *doctorState + doctor *doctorState // MCP install / remove overlays. Tied to the /mcp-install + // /mcp-remove slash commands. State is intentionally tiny β€” install diff --git a/tui/internal/ui/archived_view_test.go b/tui/internal/ui/archived_view_test.go index 0de6b8ac..b8f783d7 100644 --- a/tui/internal/ui/archived_view_test.go +++ b/tui/internal/ui/archived_view_test.go @@ -55,8 +55,8 @@ func TestArchivedView_HTogglesFetchesWithFilter(t *testing.T) { // Spy on ListSessions requests so we can assert the `archived=true` // filter was sent when the view is toggled. var ( - mu sync.Mutex - queries []string + mu sync.Mutex + queries []string ) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1/sessions" { diff --git a/tui/internal/ui/catalog_browser.go b/tui/internal/ui/catalog_browser.go index aa3541cd..3cedf5f1 100644 --- a/tui/internal/ui/catalog_browser.go +++ b/tui/internal/ui/catalog_browser.go @@ -94,13 +94,13 @@ func loadCatalogBrowserCmd(c *client.Client, kind catalogBrowserKind) tea.Cmd { items := make([]catalogItem, 0, len(servers)) for _, s := range servers { // McpServer.Status covers connecting|ready|error| - // disconnected. Simplify to connected vs not for the - // modal's status tag so the glance interpretation is - // unambiguous. - status := "disconnected" - if s.Status == "ready" || s.Status == "connected" { - status = "connected" - } + // disconnected. Simplify to connected vs not for the + // modal's status tag so the glance interpretation is + // unambiguous. + status := "disconnected" + if s.Status == "ready" || s.Status == "connected" { + status = "connected" + } // Title already shows the server name; description is just // the transport so each row reads as a single line plus a // muted transport hint (was repeating the name twice). diff --git a/tui/internal/ui/commands_extra.go b/tui/internal/ui/commands_extra.go index 45aa53e2..42376df6 100644 --- a/tui/internal/ui/commands_extra.go +++ b/tui/internal/ui/commands_extra.go @@ -45,11 +45,11 @@ func (a *App) copyLastAssistantReplyToClipboard() string { } candidates := [][]string{ - {"wl-copy"}, // Wayland + {"wl-copy"}, // Wayland {"xclip", "-selection", "clipboard"}, {"xsel", "--clipboard", "--input"}, - {"pbcopy"}, // macOS - {"clip.exe"}, // Windows / WSL + {"pbcopy"}, // macOS + {"clip.exe"}, // Windows / WSL } for _, c := range candidates { if _, err := exec.LookPath(c[0]); err != nil { diff --git a/tui/internal/ui/compose.go b/tui/internal/ui/compose.go index 1788f436..61d3f17f 100644 --- a/tui/internal/ui/compose.go +++ b/tui/internal/ui/compose.go @@ -2,15 +2,15 @@ // current input draft, useful for long prompts or reviewing pasted // code. The user's flow: // -// Input focus β†’ Ctrl+G (or Ctrl+Shift+P on terminals that send it) -// opens the compose modal with the current draft. -// Inside the modal β†’ normal textarea editing, ALL pastes land -// expanded (no compression) so users can see everything. -// Ctrl+S commits the modal body back to the base input and -// closes the modal. Esc cancels and preserves the pre-modal -// draft. -// From the base input after Ctrl+S β†’ Enter still sends, same as -// before. +// Input focus β†’ Ctrl+G (or Ctrl+Shift+P on terminals that send it) +// opens the compose modal with the current draft. +// Inside the modal β†’ normal textarea editing, ALL pastes land +// expanded (no compression) so users can see everything. +// Ctrl+S commits the modal body back to the base input and +// closes the modal. Esc cancels and preserves the pre-modal +// draft. +// From the base input after Ctrl+S β†’ Enter still sends, same as +// before. // // Design note: we deliberately reuse bubbles/v2/textarea rather than // building a second editor. The base input is single-line-ish by @@ -22,9 +22,9 @@ package ui import ( "strings" - tea "charm.land/bubbletea/v2" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textarea" + tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) diff --git a/tui/internal/ui/cursor_ctrl_e_test.go b/tui/internal/ui/cursor_ctrl_e_test.go index 8fa99588..d4dc9e1c 100644 --- a/tui/internal/ui/cursor_ctrl_e_test.go +++ b/tui/internal/ui/cursor_ctrl_e_test.go @@ -154,7 +154,7 @@ func TestCtrlE_FallbackLatestWhenNoCursor(t *testing.T) { return gact.Message{ ID: id, Role: "tool", Parts: []gact.Part{{ - Type: gact.PartTypeToolResult, + Type: gact.PartTypeToolResult, Content: []gact.Part{{Type: gact.PartTypeText, Text: id + "\n" + bulky}}, }}, } diff --git a/tui/internal/ui/detail_view_test.go b/tui/internal/ui/detail_view_test.go index 2d44a6db..4ce6d9c3 100644 --- a/tui/internal/ui/detail_view_test.go +++ b/tui/internal/ui/detail_view_test.go @@ -202,7 +202,7 @@ func TestDetailView_CtrlEOpensWithNewest(t *testing.T) { a.focus = FocusBody a.messages = []gact.Message{{ Role: gact.RoleTool, Parts: []gact.Part{{ - Type: gact.PartTypeToolResult, + Type: gact.PartTypeToolResult, Content: []gact.Part{{Type: gact.PartTypeText, Text: strings.Repeat("x\n", 20)}}, }}, }} diff --git a/tui/internal/ui/doctor_test.go b/tui/internal/ui/doctor_test.go index 8c167c1c..823305e0 100644 --- a/tui/internal/ui/doctor_test.go +++ b/tui/internal/ui/doctor_test.go @@ -31,16 +31,16 @@ func TestDoctor_RendersIntegrationsTable(t *testing.T) { out := stripANSI(a.viewDoctor()) for _, want := range []string{ - "Doctor", // modal title - "degraded", // overall_status chip - "uptime 1h 2m", // header formatted - "lm", // integration row - "ready", // status cell - "openai/gpt-4o-mini", // detail column - "clio_core", // another row - "unavailable", // degraded row status - "binary missing", // its detail - "Esc", // keybinding hint + "Doctor", // modal title + "degraded", // overall_status chip + "uptime 1h 2m", // header formatted + "lm", // integration row + "ready", // status cell + "openai/gpt-4o-mini", // detail column + "clio_core", // another row + "unavailable", // degraded row status + "binary missing", // its detail + "Esc", // keybinding hint } { if !strings.Contains(out, want) { t.Errorf("viewDoctor output missing %q; full:\n%s", want, out) diff --git a/tui/internal/ui/file_picker.go b/tui/internal/ui/file_picker.go index 81a80d57..f8db278a 100644 --- a/tui/internal/ui/file_picker.go +++ b/tui/internal/ui/file_picker.go @@ -2,19 +2,19 @@ // new word in the input opens a floating fuzzy-file picker scoped to the // current workspace. Selecting a file: // -// 1. Inserts `@path/to/file` into the input at the cursor position. -// 2. Attaches the file to the session's context via POST -// /v1/sessions/{id}/context/files (mode=read) so the backend sees -// it as extra context on the next send. Same plumbing as the K14 -// sidebar `o` key, reached from the input side. +// 1. Inserts `@path/to/file` into the input at the cursor position. +// 2. Attaches the file to the session's context via POST +// /v1/sessions/{id}/context/files (mode=read) so the backend sees +// it as extra context on the next send. Same plumbing as the K14 +// sidebar `o` key, reached from the input side. // // Design: -// * Fuzzy matching is simple case-insensitive substring scoring. Good +// - Fuzzy matching is simple case-insensitive substring scoring. Good // enough for the sizes we're dealing with (workspace listings are // typically hundreds of entries, not thousands) and debuggable. -// * Files only β€” directories are skipped. The @-syntax refers to +// - Files only β€” directories are skipped. The @-syntax refers to // concrete files; directories confuse the context-attach semantics. -// * The picker modal sits above the input and uses the same centred +// - The picker modal sits above the input and uses the same centred // spliceRow overlay as every other modal so the base view stays // visible behind the gutter. package ui @@ -91,13 +91,13 @@ func (a *App) closeFilePicker() { // ordering is deterministic across renders. // // Scoring rules (lower is better): -// * a direct substring match beats a scattered-char match β€” the +// - a direct substring match beats a scattered-char match β€” the // substring score is its 0-based start index plus a small // constant, so "rout" against "router.go" scores 0, a skip-match // scores in the hundreds. -// * for skip-match, we prefer matches that start earlier in the +// - for skip-match, we prefer matches that start earlier in the // path and have less gap between characters. -// * matches on the basename (after the last '/') beat matches that +// - matches on the basename (after the last '/') beat matches that // land earlier in a directory component β€” users typing "picker" // mean the file, not a directory called "picker-notes". func (a *App) filePickerMatches() []gact.FileEntry { @@ -140,10 +140,10 @@ func (a *App) filePickerMatches() []gact.FileEntry { // match hay at all. Both inputs must be lowercased. // // The score blends: -// * substring bonus: needle is a direct substring β†’ base_cost + idx -// * basename bonus: matches in the filename component beat matches +// - substring bonus: needle is a direct substring β†’ base_cost + idx +// - basename bonus: matches in the filename component beat matches // in parent directories -// * skip penalty: for scattered matches, each gap costs 10 so +// - skip penalty: for scattered matches, each gap costs 10 so // "router" beats "r...o...u..t..e..r" in an unrelated file // // This intentionally avoids a proper edit-distance algorithm; the diff --git a/tui/internal/ui/layout_fixes_test.go b/tui/internal/ui/layout_fixes_test.go index 83f815f1..09ba4442 100644 --- a/tui/internal/ui/layout_fixes_test.go +++ b/tui/internal/ui/layout_fixes_test.go @@ -750,7 +750,7 @@ func TestTimestampToggle_FlipsAndRenders(t *testing.T) { { ID: "m1", SessionID: "sess_1", Role: gact.RoleUser, CreatedAt: ts, - Parts: []gact.Part{{ID: "p1", Type: gact.PartTypeText, Text: "hi"}}, + Parts: []gact.Part{{ID: "p1", Type: gact.PartTypeText, Text: "hi"}}, }, } a := newReadyApp(sessions, msgs) @@ -849,11 +849,11 @@ func TestPaletteCurrentValue_HintsForKnownCommands(t *testing.T) { a.currentStatus = gact.StatusRunning cases := map[string]string{ - "/theme": "current: dracula", - "/clear": "1 messages", - "/cancel": "status: running", - "/agent": "current: code_reviewer", - "/rename": "current: refactor auth", + "/theme": "current: dracula", + "/clear": "1 messages", + "/cancel": "status: running", + "/agent": "current: code_reviewer", + "/rename": "current: refactor auth", "/completely_unknown_cmd": "", } for id, want := range cases { @@ -1162,8 +1162,8 @@ func TestInputPane_GrowsWithContent(t *testing.T) { // Settings tab 1, everything else falls through to RunCommand. func TestCatalogBrowser_CommandIDsRoute(t *testing.T) { cases := []struct { - in string - wantOk bool + in string + wantOk bool wantKind catalogBrowserKind }{ {"/mcp", true, catalogKindMcp}, diff --git a/tui/internal/ui/lm_config.go b/tui/internal/ui/lm_config.go index 3b7da49a..70bb4445 100644 --- a/tui/internal/ui/lm_config.go +++ b/tui/internal/ui/lm_config.go @@ -116,7 +116,7 @@ func (s *lmConfigState) lmConfigVisibleFields() []lmConfigField { return out } -// lmConfigStepField moves the cursor by ``delta`` (Β±1) through the +// lmConfigStepField moves the cursor by β€œdeltaβ€œ (Β±1) through the // visible-field list, wrapping at both ends. func (s *lmConfigState) lmConfigStepField(delta int) { visible := s.lmConfigVisibleFields() @@ -778,7 +778,7 @@ func (a *App) renderLMConfigAdvanced(innerW int) []string { } // lmConfigWindow returns [start, end) β€” the window of catalog rows -// to render around ``cursor``, ensuring the cursor sits roughly mid- +// to render around β€œcursorβ€œ, ensuring the cursor sits roughly mid- // window. func lmConfigWindow(cursor, total int) (int, int) { if total <= lmConfigVisibleRows { diff --git a/tui/internal/ui/mcp_manage.go b/tui/internal/ui/mcp_manage.go index 2c1f4473..870dd898 100644 --- a/tui/internal/ui/mcp_manage.go +++ b/tui/internal/ui/mcp_manage.go @@ -123,8 +123,9 @@ func (a *App) handleMcpRemoveKey(k tea.KeyPressMsg) (tea.Model, tea.Cmd) { // parseMcpInstallLine parses one line of user input into the request body // the backend expects. Supported shapes: -// stdio [args...] -// http +// +// stdio [args...] +// http func parseMcpInstallLine(line string) (map[string]any, error) { tokens := strings.Fields(strings.TrimSpace(line)) if len(tokens) < 3 { diff --git a/tui/internal/ui/memory_chip_test.go b/tui/internal/ui/memory_chip_test.go index 4b4e0fa9..3df023ab 100644 --- a/tui/internal/ui/memory_chip_test.go +++ b/tui/internal/ui/memory_chip_test.go @@ -16,9 +16,9 @@ func TestFooter_MemoryChip_RendersWhenCapAndStats(t *testing.T) { a.caps.Capabilities.Memory = true a.memoryStats = gact.MemoryStats{ Cache: gact.CacheStats{ - Hits: 80, - Misses: 20, - HitRate: 0.80, + Hits: 80, + Misses: 20, + HitRate: 0.80, Capacity: 1000, }, } diff --git a/tui/internal/ui/quit_confirm.go b/tui/internal/ui/quit_confirm.go index dfeabfe2..4986b2b0 100644 --- a/tui/internal/ui/quit_confirm.go +++ b/tui/internal/ui/quit_confirm.go @@ -161,4 +161,3 @@ func (a *App) viewQuitConfirm() string { Width(w). Render(box) } - diff --git a/tui/internal/ui/render.go b/tui/internal/ui/render.go index 0b5fed2e..5357c28c 100644 --- a/tui/internal/ui/render.go +++ b/tui/internal/ui/render.go @@ -375,7 +375,7 @@ func (t Theme) renderPartsForRoleWithResultsSelected(parts []gact.Part, width in } var rendered string switch { - case role == gact.RoleAssistant && p.Type == gact.PartTypeText && p.Text != "" : + case role == gact.RoleAssistant && p.Type == gact.PartTypeText && p.Text != "": rendered = renderMarkdown(p.Text, t, width-2) case p.Type == gact.PartTypeToolCall && p.ToolName == "edit_file": // Always render the call header (matches CC style where diff --git a/tui/internal/ui/styles.go b/tui/internal/ui/styles.go index 22486b19..b92993b6 100644 --- a/tui/internal/ui/styles.go +++ b/tui/internal/ui/styles.go @@ -184,18 +184,18 @@ func DefaultTheme() Theme { // saturated accents that survive a low-contrast display. func LightTheme() Theme { return Theme{ - Bg: lipgloss.Color("#FBF1C7"), // cream - BgSubtle: lipgloss.Color("#EBDBB2"), - Fg: lipgloss.Color("#3C3836"), - FgMuted: lipgloss.Color("#7C6F64"), - FgFaint: lipgloss.Color("#BDAE93"), - Primary: lipgloss.Color("#B16286"), // magenta - Secondary: lipgloss.Color("#076678"), // teal - Success: lipgloss.Color("#79740E"), - Warning: lipgloss.Color("#B57614"), - Danger: lipgloss.Color("#9D0006"), - Border: lipgloss.Color("#D5C4A1"), - BorderFocus: lipgloss.Color("#B16286"), + Bg: lipgloss.Color("#FBF1C7"), // cream + BgSubtle: lipgloss.Color("#EBDBB2"), + Fg: lipgloss.Color("#3C3836"), + FgMuted: lipgloss.Color("#7C6F64"), + FgFaint: lipgloss.Color("#BDAE93"), + Primary: lipgloss.Color("#B16286"), // magenta + Secondary: lipgloss.Color("#076678"), // teal + Success: lipgloss.Color("#79740E"), + Warning: lipgloss.Color("#B57614"), + Danger: lipgloss.Color("#9D0006"), + Border: lipgloss.Color("#D5C4A1"), + BorderFocus: lipgloss.Color("#B16286"), RoleUser: lipgloss.Color("#076678"), RoleAssistant: lipgloss.Color("#8F3F71"), RoleSystem: lipgloss.Color("#7C6F64"), @@ -207,18 +207,18 @@ func LightTheme() Theme { // Purple/pink/cyan on near-black; high contrast across the board. func DraculaTheme() Theme { return Theme{ - Bg: lipgloss.Color("#282A36"), - BgSubtle: lipgloss.Color("#1E1F29"), - Fg: lipgloss.Color("#F8F8F2"), - FgMuted: lipgloss.Color("#A4A6B5"), - FgFaint: lipgloss.Color("#6272A4"), - Primary: lipgloss.Color("#BD93F9"), // purple - Secondary: lipgloss.Color("#50FA7B"), // green - Success: lipgloss.Color("#50FA7B"), - Warning: lipgloss.Color("#FFB86C"), - Danger: lipgloss.Color("#FF5555"), - Border: lipgloss.Color("#44475A"), - BorderFocus: lipgloss.Color("#BD93F9"), + Bg: lipgloss.Color("#282A36"), + BgSubtle: lipgloss.Color("#1E1F29"), + Fg: lipgloss.Color("#F8F8F2"), + FgMuted: lipgloss.Color("#A4A6B5"), + FgFaint: lipgloss.Color("#6272A4"), + Primary: lipgloss.Color("#BD93F9"), // purple + Secondary: lipgloss.Color("#50FA7B"), // green + Success: lipgloss.Color("#50FA7B"), + Warning: lipgloss.Color("#FFB86C"), + Danger: lipgloss.Color("#FF5555"), + Border: lipgloss.Color("#44475A"), + BorderFocus: lipgloss.Color("#BD93F9"), RoleUser: lipgloss.Color("#8BE9FD"), // cyan RoleAssistant: lipgloss.Color("#FF79C6"), // pink RoleSystem: lipgloss.Color("#6272A4"), @@ -230,18 +230,18 @@ func DraculaTheme() Theme { // design β€” lets long reading sessions not tire the eyes. func SolarizedDarkTheme() Theme { return Theme{ - Bg: lipgloss.Color("#002B36"), // base03 - BgSubtle: lipgloss.Color("#073642"), // base02 - Fg: lipgloss.Color("#93A1A1"), // base1 - FgMuted: lipgloss.Color("#839496"), // base0 - FgFaint: lipgloss.Color("#586E75"), // base01 - Primary: lipgloss.Color("#268BD2"), // blue - Secondary: lipgloss.Color("#2AA198"), // cyan - Success: lipgloss.Color("#859900"), // green - Warning: lipgloss.Color("#B58900"), // yellow - Danger: lipgloss.Color("#DC322F"), // red - Border: lipgloss.Color("#586E75"), - BorderFocus: lipgloss.Color("#268BD2"), + Bg: lipgloss.Color("#002B36"), // base03 + BgSubtle: lipgloss.Color("#073642"), // base02 + Fg: lipgloss.Color("#93A1A1"), // base1 + FgMuted: lipgloss.Color("#839496"), // base0 + FgFaint: lipgloss.Color("#586E75"), // base01 + Primary: lipgloss.Color("#268BD2"), // blue + Secondary: lipgloss.Color("#2AA198"), // cyan + Success: lipgloss.Color("#859900"), // green + Warning: lipgloss.Color("#B58900"), // yellow + Danger: lipgloss.Color("#DC322F"), // red + Border: lipgloss.Color("#586E75"), + BorderFocus: lipgloss.Color("#268BD2"), RoleUser: lipgloss.Color("#268BD2"), RoleAssistant: lipgloss.Color("#6C71C4"), // violet RoleSystem: lipgloss.Color("#586E75"), @@ -253,18 +253,18 @@ func SolarizedDarkTheme() Theme { // colours as SolarizedDark on an inverted base. func SolarizedLightTheme() Theme { return Theme{ - Bg: lipgloss.Color("#FDF6E3"), // base3 - BgSubtle: lipgloss.Color("#EEE8D5"), // base2 - Fg: lipgloss.Color("#586E75"), // base01 - FgMuted: lipgloss.Color("#657B83"), // base00 - FgFaint: lipgloss.Color("#93A1A1"), // base1 - Primary: lipgloss.Color("#268BD2"), - Secondary: lipgloss.Color("#2AA198"), - Success: lipgloss.Color("#859900"), - Warning: lipgloss.Color("#B58900"), - Danger: lipgloss.Color("#DC322F"), - Border: lipgloss.Color("#93A1A1"), - BorderFocus: lipgloss.Color("#268BD2"), + Bg: lipgloss.Color("#FDF6E3"), // base3 + BgSubtle: lipgloss.Color("#EEE8D5"), // base2 + Fg: lipgloss.Color("#586E75"), // base01 + FgMuted: lipgloss.Color("#657B83"), // base00 + FgFaint: lipgloss.Color("#93A1A1"), // base1 + Primary: lipgloss.Color("#268BD2"), + Secondary: lipgloss.Color("#2AA198"), + Success: lipgloss.Color("#859900"), + Warning: lipgloss.Color("#B58900"), + Danger: lipgloss.Color("#DC322F"), + Border: lipgloss.Color("#93A1A1"), + BorderFocus: lipgloss.Color("#268BD2"), RoleUser: lipgloss.Color("#268BD2"), RoleAssistant: lipgloss.Color("#6C71C4"), RoleSystem: lipgloss.Color("#93A1A1"), @@ -276,18 +276,18 @@ func SolarizedLightTheme() Theme { // tones on a deep navy background. func NordTheme() Theme { return Theme{ - Bg: lipgloss.Color("#2E3440"), // nord0 - BgSubtle: lipgloss.Color("#3B4252"), // nord1 - Fg: lipgloss.Color("#ECEFF4"), // nord6 - FgMuted: lipgloss.Color("#D8DEE9"), // nord4 - FgFaint: lipgloss.Color("#616E88"), - Primary: lipgloss.Color("#88C0D0"), // nord8 β€” frost - Secondary: lipgloss.Color("#A3BE8C"), // nord14 β€” aurora green - Success: lipgloss.Color("#A3BE8C"), - Warning: lipgloss.Color("#EBCB8B"), // nord13 - Danger: lipgloss.Color("#BF616A"), // nord11 - Border: lipgloss.Color("#434C5E"), - BorderFocus: lipgloss.Color("#88C0D0"), + Bg: lipgloss.Color("#2E3440"), // nord0 + BgSubtle: lipgloss.Color("#3B4252"), // nord1 + Fg: lipgloss.Color("#ECEFF4"), // nord6 + FgMuted: lipgloss.Color("#D8DEE9"), // nord4 + FgFaint: lipgloss.Color("#616E88"), + Primary: lipgloss.Color("#88C0D0"), // nord8 β€” frost + Secondary: lipgloss.Color("#A3BE8C"), // nord14 β€” aurora green + Success: lipgloss.Color("#A3BE8C"), + Warning: lipgloss.Color("#EBCB8B"), // nord13 + Danger: lipgloss.Color("#BF616A"), // nord11 + Border: lipgloss.Color("#434C5E"), + BorderFocus: lipgloss.Color("#88C0D0"), RoleUser: lipgloss.Color("#81A1C1"), // nord9 RoleAssistant: lipgloss.Color("#B48EAD"), // nord15 β€” aurora purple RoleSystem: lipgloss.Color("#4C566A"), @@ -299,18 +299,18 @@ func NordTheme() Theme { // the "cyberpunk glow" look popular in VS Code. func TokyoNightTheme() Theme { return Theme{ - Bg: lipgloss.Color("#1A1B26"), - BgSubtle: lipgloss.Color("#16161E"), - Fg: lipgloss.Color("#C0CAF5"), - FgMuted: lipgloss.Color("#A9B1D6"), - FgFaint: lipgloss.Color("#565F89"), - Primary: lipgloss.Color("#BB9AF7"), // purple - Secondary: lipgloss.Color("#7AA2F7"), // blue - Success: lipgloss.Color("#9ECE6A"), // green - Warning: lipgloss.Color("#E0AF68"), // orange - Danger: lipgloss.Color("#F7768E"), // pink-red - Border: lipgloss.Color("#3B4261"), - BorderFocus: lipgloss.Color("#BB9AF7"), + Bg: lipgloss.Color("#1A1B26"), + BgSubtle: lipgloss.Color("#16161E"), + Fg: lipgloss.Color("#C0CAF5"), + FgMuted: lipgloss.Color("#A9B1D6"), + FgFaint: lipgloss.Color("#565F89"), + Primary: lipgloss.Color("#BB9AF7"), // purple + Secondary: lipgloss.Color("#7AA2F7"), // blue + Success: lipgloss.Color("#9ECE6A"), // green + Warning: lipgloss.Color("#E0AF68"), // orange + Danger: lipgloss.Color("#F7768E"), // pink-red + Border: lipgloss.Color("#3B4261"), + BorderFocus: lipgloss.Color("#BB9AF7"), RoleUser: lipgloss.Color("#7DCFFF"), // cyan RoleAssistant: lipgloss.Color("#BB9AF7"), RoleSystem: lipgloss.Color("#565F89"), diff --git a/tui/internal/ui/theme_custom.go b/tui/internal/ui/theme_custom.go index b93d0526..e7386e8f 100644 --- a/tui/internal/ui/theme_custom.go +++ b/tui/internal/ui/theme_custom.go @@ -9,16 +9,16 @@ // // Example ~/.config/gact/theme.json: // -// { -// "name": "my-theme", -// "bg": "#0F0F14", -// "fg": "#EDEDED", -// "primary": "#FF79C6", -// "secondary": "#8BE9FD", -// "warning": "#F2C94C", -// "role_user": "#5BC0EB", -// "role_assistant": "#FF79C6" -// } +// { +// "name": "my-theme", +// "bg": "#0F0F14", +// "fg": "#EDEDED", +// "primary": "#FF79C6", +// "secondary": "#8BE9FD", +// "warning": "#F2C94C", +// "role_user": "#5BC0EB", +// "role_assistant": "#FF79C6" +// } // // The fields mirror the Theme struct's colour-only fields. Style // objects (Pane, Header, etc.) are rebuilt via applyStyles so users diff --git a/tui/main.go b/tui/main.go index 0d2abc5b..e5b9ccbb 100644 --- a/tui/main.go +++ b/tui/main.go @@ -263,7 +263,7 @@ func readVCSInfo() (rev, when string, dirty bool) { // value β€” JSON doesn't allow comments, so the field names themselves // serve as documentation. Users redirect to the canonical path: // -// gact emit-config > ~/.config/gact/config.json +// gact emit-config > ~/.config/gact/config.json func runEmitConfig() { bk := "http://localhost:7777" th := "dark" @@ -2034,9 +2034,9 @@ func runFollow(args []string) int { known := map[string]bool{ "--backend": true, "-backend": true, "--format": true, "-format": true, - "--role": true, "-role": true, - "--grep": true, "-grep": true, - "--since": true, "-since": true, + "--role": true, "-role": true, + "--grep": true, "-grep": true, + "--since": true, "-since": true, } if err := fs.Parse(reorderFlagsFirst(args, known)); err != nil { return 2 @@ -2230,7 +2230,7 @@ func runGrep(args []string) int { "--workspace": true, "-workspace": true, "--format": true, "-format": true, "--limit": true, "-limit": true, - "--role": true, "-role": true, + "--role": true, "-role": true, } if err := fs.Parse(reorderFlagsFirst(args, known)); err != nil { return 2 @@ -2321,7 +2321,7 @@ func runGrep(args []string) int { } hits = append(hits, hit{ SID: s.ID, Title: s.Title, MID: m.MessageID, - Role: role, + Role: role, Snippet: strings.ReplaceAll(m.Snippet, "\n", " "), }) } @@ -2385,7 +2385,7 @@ func runDashboard(args []string) int { "--format": true, "-format": true, "--interval": true, "-interval": true, "--status": true, "-status": true, - "--sort": true, "-sort": true, + "--sort": true, "-sort": true, "--detached-only": true, "-detached-only": true, })); err != nil { return 2 @@ -2471,10 +2471,10 @@ func runDetached(args []string) int { interval := fs.Duration("interval", 2*time.Second, "refresh cadence in --watch mode") if err := fs.Parse(reorderFlagsFirst(args, map[string]bool{ "--rm": true, "-rm": true, - "--probe": true, "-probe": true, + "--probe": true, "-probe": true, "--prune-dead": true, "-prune-dead": true, - "--format": true, "-format": true, - "--watch": true, "-watch": true, + "--format": true, "-format": true, + "--watch": true, "-watch": true, "--interval": true, "-interval": true, })); err != nil { return 2 @@ -2547,122 +2547,122 @@ func runDetached(args []string) int { liveness[i] = &alive } } - // GGGGGGGG1: --prune-dead removes every entry whose probe came - // back negative. Done after the probe pass so the rendered table - // (below) shows the survivors with their (alive=yes) column, - // confirming what's left. The dead rows themselves are dropped - // silently from the rendered output but counted in stderr. - if *pruneDead { - survivors := reg.Records[:0] - survivorLive := liveness[:0] - removed := 0 - for i, r := range reg.Records { - if liveness[i] != nil && !*liveness[i] { - removed++ - continue + // GGGGGGGG1: --prune-dead removes every entry whose probe came + // back negative. Done after the probe pass so the rendered table + // (below) shows the survivors with their (alive=yes) column, + // confirming what's left. The dead rows themselves are dropped + // silently from the rendered output but counted in stderr. + if *pruneDead { + survivors := reg.Records[:0] + survivorLive := liveness[:0] + removed := 0 + for i, r := range reg.Records { + if liveness[i] != nil && !*liveness[i] { + removed++ + continue + } + survivors = append(survivors, r) + survivorLive = append(survivorLive, liveness[i]) + } + reg.Records = survivors + liveness = survivorLive + if removed > 0 { + if err := config.SaveDetached(reg, path); err != nil { + fmt.Fprintf(os.Stderr, "gact detached: prune-dead: write %s: %v\n", path, err) + return 1 + } } - survivors = append(survivors, r) - survivorLive = append(survivorLive, liveness[i]) + fmt.Fprintf(os.Stderr, "pruned %d dead entr(y/ies); %d alive remain\n", + removed, len(reg.Records)) } - reg.Records = survivors - liveness = survivorLive - if removed > 0 { - if err := config.SaveDetached(reg, path); err != nil { - fmt.Fprintf(os.Stderr, "gact detached: prune-dead: write %s: %v\n", path, err) - return 1 + switch *format { + case "json": + type row struct { + config.DetachedRecord + Alive *bool `json:"alive,omitempty"` } - } - fmt.Fprintf(os.Stderr, "pruned %d dead entr(y/ies); %d alive remain\n", - removed, len(reg.Records)) - } - switch *format { - case "json": - type row struct { - config.DetachedRecord - Alive *bool `json:"alive,omitempty"` - } - rows := make([]row, len(reg.Records)) - for i, r := range reg.Records { - rows[i] = row{DetachedRecord: r, Alive: liveness[i]} - } - b, _ := json.MarshalIndent(rows, "", " ") - fmt.Println(string(b)) - case "tsv": - fmt.Println("session_id\ttitle\tbackend\tworkspace\tdetached_at\talive") - for i, r := range reg.Records { - alive := "" - if liveness[i] != nil { - if *liveness[i] { - alive = "yes" - } else { - alive = "no" - } + rows := make([]row, len(reg.Records)) + for i, r := range reg.Records { + rows[i] = row{DetachedRecord: r, Alive: liveness[i]} } - fmt.Printf("%s\t%s\t%s\t%s\t%s\t%s\n", - r.SessionID, r.Title, r.Backend, r.Workspace, - r.DetachedAt.Format(time.RFC3339), alive) - } - default: // pretty - if len(reg.Records) == 0 { - fmt.Println("(no detached sessions β€” Ctrl+Z in the TUI records one here)") - return 0 - } - // BBBBBBBB2: reorder so dead entries sink to the bottom when - // --probe is set β€” the user's next reattach target is almost - // always one of the live ones. Stable sort preserves the - // newest-first ordering within each group. - order := make([]int, len(reg.Records)) - for i := range order { - order[i] = i - } - sort.SliceStable(order, func(i, j int) bool { - ai, aj := liveness[order[i]], liveness[order[j]] - // Both unprobed or same liveness β†’ preserve index order. - if ai == nil && aj == nil { - return order[i] < order[j] + b, _ := json.MarshalIndent(rows, "", " ") + fmt.Println(string(b)) + case "tsv": + fmt.Println("session_id\ttitle\tbackend\tworkspace\tdetached_at\talive") + for i, r := range reg.Records { + alive := "" + if liveness[i] != nil { + if *liveness[i] { + alive = "yes" + } else { + alive = "no" + } + } + fmt.Printf("%s\t%s\t%s\t%s\t%s\t%s\n", + r.SessionID, r.Title, r.Backend, r.Workspace, + r.DetachedAt.Format(time.RFC3339), alive) } - // Alive-or-unknown ranks above known-dead. - iDead := ai != nil && !*ai - jDead := aj != nil && !*aj - if iDead != jDead { - return !iDead + default: // pretty + if len(reg.Records) == 0 { + fmt.Println("(no detached sessions β€” Ctrl+Z in the TUI records one here)") + return 0 } - return order[i] < order[j] - }) - fmt.Printf("%-20s %-30s %-30s %-12s %s\n", - "SESSION", "TITLE", "BACKEND", "DETACHED", "ALIVE") - alive, dead, unknown := 0, 0, 0 - for _, idx := range order { - r := reg.Records[idx] - aliveText := "?" - col := ansiDim - if liveness[idx] != nil { - if *liveness[idx] { - aliveText, col = "yes", ansiGreen - alive++ + // BBBBBBBB2: reorder so dead entries sink to the bottom when + // --probe is set β€” the user's next reattach target is almost + // always one of the live ones. Stable sort preserves the + // newest-first ordering within each group. + order := make([]int, len(reg.Records)) + for i := range order { + order[i] = i + } + sort.SliceStable(order, func(i, j int) bool { + ai, aj := liveness[order[i]], liveness[order[j]] + // Both unprobed or same liveness β†’ preserve index order. + if ai == nil && aj == nil { + return order[i] < order[j] + } + // Alive-or-unknown ranks above known-dead. + iDead := ai != nil && !*ai + jDead := aj != nil && !*aj + if iDead != jDead { + return !iDead + } + return order[i] < order[j] + }) + fmt.Printf("%-20s %-30s %-30s %-12s %s\n", + "SESSION", "TITLE", "BACKEND", "DETACHED", "ALIVE") + alive, dead, unknown := 0, 0, 0 + for _, idx := range order { + r := reg.Records[idx] + aliveText := "?" + col := ansiDim + if liveness[idx] != nil { + if *liveness[idx] { + aliveText, col = "yes", ansiGreen + alive++ + } else { + aliveText, col = "no", ansiRed + dead++ + } } else { - aliveText, col = "no", ansiRed - dead++ + unknown++ } - } else { - unknown++ + when := humanizeAge(time.Since(r.DetachedAt)) + title := r.Title + if title == "" { + title = "(untitled)" + } + fmt.Printf("%-20s %-30s %-30s %-12s %s\n", + truncMid(r.SessionID, 20), truncMid(title, 30), + truncMid(r.Backend, 30), when, colorize(aliveText, col)) } - when := humanizeAge(time.Since(r.DetachedAt)) - title := r.Title - if title == "" { - title = "(untitled)" + fmt.Println() + // Footer summary β€” only show probe counts if at least one + // row was probed (otherwise the zeros are noise). + if alive+dead > 0 { + fmt.Printf("%d alive Β· %d dead Β· %d unprobed\n", alive, dead, unknown) } - fmt.Printf("%-20s %-30s %-30s %-12s %s\n", - truncMid(r.SessionID, 20), truncMid(title, 30), - truncMid(r.Backend, 30), when, colorize(aliveText, col)) - } - fmt.Println() - // Footer summary β€” only show probe counts if at least one - // row was probed (otherwise the zeros are noise). - if alive+dead > 0 { - fmt.Printf("%d alive Β· %d dead Β· %d unprobed\n", alive, dead, unknown) - } - fmt.Println("Reattach: gact attach ") + fmt.Println("Reattach: gact attach ") } return 0 } @@ -2896,7 +2896,7 @@ func runAgentDeploy(args []string) int { if err := fs.Parse(reorderFlagsFirst(args, map[string]bool{ "--bin": true, "-bin": true, "--port": true, "-port": true, - "--cwd": true, "-cwd": true, + "--cwd": true, "-cwd": true, "--host": true, "-host": true, })); err != nil { return 2 @@ -3237,7 +3237,6 @@ func runAgentConnect(args []string) int { return 0 } - // colorize wraps s in an ANSI sequence when stdout is a terminal // (detected via file-mode check) β€” otherwise returns the raw string // so piped output isn't cluttered with escape codes. Matches the @@ -3525,9 +3524,10 @@ func humanTokensCLI(n int) string { // SPEC without writing test code (SSS1). // // Exit codes: -// 0 β€” every section passed (or was explicitly skipped) -// 1 β€” at least one section failed -// 2 β€” bad usage +// +// 0 β€” every section passed (or was explicitly skipped) +// 1 β€” at least one section failed +// 2 β€” bad usage func runConformance(args []string) int { fs := flag.NewFlagSet("conformance", flag.ContinueOnError) backend := fs.String("backend", defaultBackend, "GACT backend URL") @@ -6717,8 +6717,8 @@ func runLog(args []string) int { "--limit": true, "-limit": true, "--since": true, "-since": true, "--format": true, "-format": true, - "--role": true, "-role": true, - "--grep": true, "-grep": true, + "--role": true, "-role": true, + "--grep": true, "-grep": true, } if err := fs.Parse(reorderFlagsFirst(args, known)); err != nil { return 2 @@ -7513,7 +7513,7 @@ func runExport(args []string) int { } if fs.NArg() != 1 { - fmt.Fprintln(os.Stderr, "usage: gact export [-o path] [--backend URL]\n" + + fmt.Fprintln(os.Stderr, "usage: gact export [-o path] [--backend URL]\n"+ " or: gact export --all -o DIR [--workspace WS_ID] [--backend URL]") return 2 } diff --git a/tui/main_test.go b/tui/main_test.go index 0f028f5b..9901eca6 100644 --- a/tui/main_test.go +++ b/tui/main_test.go @@ -136,7 +136,9 @@ func createSession(t *testing.T, baseURL, title string) string { if resp.StatusCode != http.StatusCreated { t.Fatalf("create session: status %d", resp.StatusCode) } - var s struct{ ID string `json:"id"` } + var s struct { + ID string `json:"id"` + } _ = json.NewDecoder(resp.Body).Decode(&s) if s.ID == "" { t.Fatal("create session: no id in response") @@ -164,8 +166,8 @@ func TestCLI_ExportToStdout(t *testing.T) { t.Fatalf("exit %d, stderr=%q", code, stderr) } var blob struct { - Format string `json:"format"` - Messages []any `json:"messages"` + Format string `json:"format"` + Messages []any `json:"messages"` } if err := json.Unmarshal([]byte(stdout), &blob); err != nil { t.Fatalf("decode: %v\nstdout: %s", err, stdout) @@ -229,9 +231,9 @@ func TestCLI_Ping(t *testing.T) { t.Fatalf("ping --json live: exit %d, stdout=%q", code, stdout) } var ok struct { - OK bool `json:"ok"` - Backend string `json:"backend"` - UptimeS int `json:"uptime_s"` + OK bool `json:"ok"` + Backend string `json:"backend"` + UptimeS int `json:"uptime_s"` } if err := json.Unmarshal([]byte(stdout), &ok); err != nil { t.Fatalf("ping --json parse: %v (raw=%q)", err, stdout) @@ -2756,11 +2758,11 @@ func TestCLI_WatchJSON(t *testing.T) { t.Fatalf("expected β‰₯2 NDJSON rows, got %d: %q", len(rows), stdout) } type rec struct { - TS string `json:"ts"` - SID string `json:"sid"` - Status string `json:"status"` - Msgs int `json:"message_count"` - Tokens int `json:"tokens_out"` + TS string `json:"ts"` + SID string `json:"sid"` + Status string `json:"status"` + Msgs int `json:"message_count"` + Tokens int `json:"tokens_out"` } sawIdle := false for i, line := range rows { @@ -4521,8 +4523,8 @@ func TestCLI_AttachPrintOnly_NoArgsReadsRegistry(t *testing.T) { } stdout, stderr, code := runGact(t, bin, map[string]string{ - "GACT_BACKEND": url, - "GACT_DETACHED_PATH": regPath, + "GACT_BACKEND": url, + "GACT_DETACHED_PATH": regPath, }, "attach", "--print-only") if code != 0 { t.Fatalf("attach --print-only no args: exit %d stderr=%q", code, stderr) @@ -4646,4 +4648,3 @@ func TestDefaultAttachTarget_AllDeadReturnsHelpfulError(t *testing.T) { t.Errorf("error should point to --probe for cleanup; got %q", err.Error()) } } -