diff --git a/internal/context/astfilter.go b/internal/context/astfilter.go new file mode 100644 index 0000000..b6ebe15 --- /dev/null +++ b/internal/context/astfilter.go @@ -0,0 +1,366 @@ +package context + +import ( + "bytes" + "fmt" + "go/ast" + "go/parser" + "go/token" + "strings" +) + +// FilteredFile carries the compact AST-derived representation of a Go source +// file, designed for LLM consumption. It strips function bodies, redundant +// comments, and boilerplate while preserving signatures and structure. +type FilteredFile struct { + Path string + Package string + Imports []string + Types []TypeSig + Funcs []FuncSig + Vars []string + Consts []string +} + +// TypeSig is a compact type definition signature. +type TypeSig struct { + Name string + Kind string // "struct", "interface", "type alias", etc. + Methods []FuncSig + Exported bool + Comment string +} + +// FuncSig is a compact function/method signature without body. +type FuncSig struct { + Name string + Params string + Results string + Recv string + Exported bool + Comment string +} + +// FilterGoSource parses a Go source file at the given path and returns a +// compact FilteredFile representation. It strips: +// - Function/method bodies (keeps only signatures) +// - Non-doc comments +// - Long literal blocks +// - Unreferenced code sections +// +// It preserves: +// - Package declaration +// - Import statements +// - Function/method signatures with receiver, params, results +// - Type definitions (struct fields as compact list, interface methods) +// - Const/var declarations (names and types only, not values) +// - Export status +func FilterGoSource(path string, src []byte) (*FilteredFile, error) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, path, src, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("ast filter: parse %s: %w", path, err) + } + + ff := &FilteredFile{ + Path: path, + Package: f.Name.Name, + } + + // Collect imports. + if f.Imports != nil { + ff.Imports = make([]string, 0, len(f.Imports)) + for _, imp := range f.Imports { + if imp.Path != nil { + path := imp.Path.Value + if imp.Name != nil { + ff.Imports = append(ff.Imports, imp.Name.Name+" "+path) + } else { + ff.Imports = append(ff.Imports, path) + } + } + } + } + + // Collect top-level declarations. + for _, decl := range f.Decls { + switch d := decl.(type) { + case *ast.GenDecl: + ff.collectGenDecl(d) + case *ast.FuncDecl: + ff.collectFuncDecl(d) + } + } + + return ff, nil +} + +func (ff *FilteredFile) collectGenDecl(d *ast.GenDecl) { + switch d.Tok { + case token.IMPORT: + return // handled above + case token.CONST: + for _, spec := range d.Specs { + if vs, ok := spec.(*ast.ValueSpec); ok { + for _, name := range vs.Names { + repr := name.Name + if vs.Type != nil { + repr += " " + typeExprString(vs.Type) + } + ff.Consts = append(ff.Consts, repr) + } + } + } + case token.VAR: + for _, spec := range d.Specs { + if vs, ok := spec.(*ast.ValueSpec); ok { + for _, name := range vs.Names { + repr := name.Name + if vs.Type != nil { + repr += " " + typeExprString(vs.Type) + } + ff.Vars = append(ff.Vars, repr) + } + } + } + case token.TYPE: + for _, spec := range d.Specs { + if ts, ok := spec.(*ast.TypeSpec); ok { + ff.collectTypeSpec(ts) + } + } + } +} + +func (ff *FilteredFile) collectTypeSpec(ts *ast.TypeSpec) { + t := TypeSig{ + Name: ts.Name.Name, + Exported: ts.Name.IsExported(), + } + if ts.Doc != nil { + t.Comment = commentText(ts.Doc) + } + + switch tt := ts.Type.(type) { + case *ast.StructType: + t.Kind = "struct" + t.Comment = fieldListCompact(tt.Fields) + case *ast.InterfaceType: + t.Kind = "interface" + t.Comment = interfaceCompact(tt.Methods) + default: + t.Kind = typeExprString(ts.Type) + } + + ff.Types = append(ff.Types, t) +} + +func (ff *FilteredFile) collectFuncDecl(d *ast.FuncDecl) { + f := FuncSig{ + Name: d.Name.Name, + Exported: d.Name.IsExported(), + } + if d.Doc != nil { + f.Comment = commentText(d.Doc) + } + if d.Recv != nil && len(d.Recv.List) > 0 { + f.Recv = fieldCompact(d.Recv.List[0]) + } + if d.Type.Params != nil { + f.Params = fieldListCompactShort(d.Type.Params) + } + if d.Type.Results != nil { + switch d.Type.Results.NumFields() { + case 0: + f.Results = "" + case 1: + f.Results = fieldCompact(d.Type.Results.List[0]) + default: + f.Results = fieldListCompactShort(d.Type.Results) + } + } + + ff.Funcs = append(ff.Funcs, f) +} + +// FilterSourceForLLM is a convenience wrapper that parses Go source bytes and +// returns the compact filtered representation as a string. If the source cannot +// be parsed (e.g., invalid Go), it returns the original source unchanged to +// avoid data loss. +func FilterSourceForLLM(path string, src []byte) string { + ff, err := FilterGoSource(path, src) + if err != nil || ff == nil { + return string(src) + } + return ff.Compact() +} + +// Compact renders the filtered file as a concise string for LLM consumption. +// Output format: +// +// package +// +// import ( +// +// ) +// +// +func (ff *FilteredFile) Compact() string { + var b bytes.Buffer + + fmt.Fprintf(&b, "package %s\n\n", ff.Package) + + if len(ff.Imports) > 0 { + b.WriteString("import (\n") + for _, imp := range ff.Imports { + fmt.Fprintf(&b, " %s\n", imp) + } + b.WriteString(")\n\n") + } + + for _, t := range ff.Types { + switch t.Kind { + case "struct": + fmt.Fprintf(&b, "type %s struct { %s }\n", t.Name, t.Comment) + case "interface": + fmt.Fprintf(&b, "type %s interface { %s }\n", t.Name, t.Comment) + default: + if t.Comment != "" { + fmt.Fprintf(&b, "// %s\n", t.Comment) + } + fmt.Fprintf(&b, "type %s %s\n", t.Name, t.Kind) + } + } + + for _, f := range ff.Funcs { + if f.Comment != "" { + b.WriteString(f.Comment) + b.WriteByte('\n') + } + if f.Exported && f.Recv == "" { + b.WriteString("// exported\n") + } + b.WriteString("func ") + if f.Recv != "" { + fmt.Fprintf(&b, "(%s) ", f.Recv) + } + fmt.Fprintf(&b, "%s(%s)", f.Name, f.Params) + if f.Results != "" { + fmt.Fprintf(&b, " %s", f.Results) + } + b.WriteString("\n\n") + } + + for _, c := range ff.Consts { + fmt.Fprintf(&b, "const %s\n", c) + } + for _, v := range ff.Vars { + fmt.Fprintf(&b, "var %s\n", v) + } + + return strings.TrimSpace(b.String()) +} + +// ── helpers ─────────────────────────────────────────────────────────── + +func typeExprString(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.Ident: + return t.Name + case *ast.SelectorExpr: + return typeExprString(t.X) + "." + t.Sel.Name + case *ast.StarExpr: + return "*" + typeExprString(t.X) + case *ast.ArrayType: + if t.Len == nil { + return "[]" + typeExprString(t.Elt) + } + return "[...]" + typeExprString(t.Elt) + case *ast.MapType: + return "map[" + typeExprString(t.Key) + "]" + typeExprString(t.Value) + case *ast.ChanType: + return "chan " + typeExprString(t.Value) + case *ast.FuncType: + return "func(" + fieldListCompactShort(t.Params) + ")" + resultString(t.Results) + case *ast.InterfaceType: + return "interface{...}" + case *ast.StructType: + return "struct{...}" + case *ast.Ellipsis: + return "..." + typeExprString(t.Elt) + default: + return fmt.Sprintf("%T", expr) + } +} + +func resultString(fields *ast.FieldList) string { + if fields == nil || fields.NumFields() == 0 { + return "" + } + if fields.NumFields() == 1 { + return " " + fieldCompact(fields.List[0]) + } + return " (" + fieldListCompactShort(fields) + ")" +} + +func fieldListCompact(fl *ast.FieldList) string { + if fl == nil { + return "" + } + parts := make([]string, 0, len(fl.List)) + for _, f := range fl.List { + parts = append(parts, fieldCompact(f)) + } + return strings.Join(parts, "; ") +} + +func fieldListCompactShort(fl *ast.FieldList) string { + if fl == nil { + return "" + } + parts := make([]string, 0, len(fl.List)) + for _, f := range fl.List { + parts = append(parts, fieldCompact(f)) + } + return strings.Join(parts, ", ") +} + +func fieldCompact(f *ast.Field) string { + var names string + if len(f.Names) > 0 { + n := make([]string, len(f.Names)) + for i, name := range f.Names { + n[i] = name.Name + } + names = strings.Join(n, ", ") + " " + } + return names + typeExprString(f.Type) +} + +func interfaceCompact(fl *ast.FieldList) string { + if fl == nil { + return "" + } + parts := make([]string, 0, len(fl.List)) + for _, f := range fl.List { + s := fieldCompact(f) + parts = append(parts, s+";") + } + return strings.Join(parts, " ") +} + +func commentText(cg *ast.CommentGroup) string { + if cg == nil { + return "" + } + var b bytes.Buffer + for _, c := range cg.List { + text := strings.TrimLeft(c.Text, "/ ") + if text != "" { + b.WriteString("// ") + b.WriteString(text) + b.WriteByte('\n') + } + } + return strings.TrimSpace(b.String()) +} diff --git a/internal/gateway/router.go b/internal/gateway/router.go index fc521d9..2ddf708 100644 --- a/internal/gateway/router.go +++ b/internal/gateway/router.go @@ -8,6 +8,9 @@ import ( "github.com/PizenLabs/izen/internal/command" ) +// hotPrefixPattern matches the $hot fast-track prefix optionally followed by a modifier. +var hotPrefixPattern = regexp.MustCompile(`^\$hot(?:\s|$)`) + // commandPrefixPattern matches known router/CLI prefixes like $prompt, /plan, etc. var commandPrefixPattern = regexp.MustCompile(`^(?:\$prompt\s+|\$ask\s+|/plan\s+|/build\s+)?`) @@ -276,6 +279,78 @@ func extractBareFilenames(msg string) []string { return files } +// IsHotTrack reports whether the input carries the $hot fast-track prefix. +// $hot bypasses ALL plan generation, diagnostic loops, and Senior Architect +// analysis, routing directly to the /build engine for instant execution. +func IsHotTrack(input string) bool { + return hotPrefixPattern.MatchString(strings.TrimSpace(input)) +} + +// HasHighIntentFlag reports whether the input explicitly requests high-intent +// analysis via --high or /intent high. +func HasHighIntentFlag(input string) bool { + lower := strings.ToLower(input) + return strings.Contains(lower, "--high") || strings.Contains(lower, "/intent high") +} + +// ClassifyIntentMode determines whether a non-diagnostic user request should +// route through /investigate (bug diagnostics) or go directly to /build +// (code creation/mutation). Returns "build" for mutation intents, "investigate" +// for bug diagnostics, and "plan" for architectural work. +// +// Rules: +// - Diagnostic patterns (why, what caused, investigate, crash, bug) → investigate +// - Mutation verbs + file refs (write test, add feature, refactor @file) → build +// - Architectural keywords (migrate, redesign, architecture) → plan +// - $hot prefix → build (bypass all) +func ClassifyIntentMode(input string) string { + if IsHotTrack(input) { + return "build" + } + lower := strings.ToLower(input) + + // Diagnostic patterns → investigate. + for _, p := range diagnosticPatterns { + if p.MatchString(lower) { + return "investigate" + } + } + + if hasDiagnosticIntent(input) { + return "investigate" + } + + // Architectural keywords → plan. + architecturalPatterns := []string{ + "architecture", "migrate", "redesign", "restructure", + "cross-cutting", "schema change", "database migration", + } + for _, p := range architecturalPatterns { + if strings.Contains(lower, p) { + return "plan" + } + } + + // Mutation intent + file ref → build. + if hasDirectMutationVerb(input) { + files := extractFileRefs(input) + if len(files) > 0 || strings.Contains(lower, "write") || + strings.Contains(lower, "create") || strings.Contains(lower, "generate") || + strings.Contains(lower, "add ") || strings.Contains(lower, "implement") { + return "build" + } + } + + // Default: let the mode resolver decide. + return "" +} + +// StripHotPrefix removes the $hot prefix from the input, returning the clean +// command string. If no $hot prefix is found, returns the input unchanged. +func StripHotPrefix(input string) string { + return hotPrefixPattern.ReplaceAllString(input, "") +} + // isDirectMutationTarget reports whether the given filename is a // documentation, config, or non-code asset that never requires // test/compile verification. diff --git a/internal/llm/openai.go b/internal/llm/openai.go index 9560c4c..978c330 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -141,6 +141,12 @@ func (c *OpenAIClient) GenerateResponse(ctx context.Context, req PromptRequest) } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) + if strings.Contains(c.baseURL, "openrouter") { + httpReq.Header.Set("HTTP-Referer", "https://pizenlabs.github.io/izen") + httpReq.Header.Set("X-Title", "izen") + httpReq.Header.Set("X-Description", "AI amplifies human judgment. Humans remain in control.") + httpReq.Header.Set("X-Categories", "cli-agent") + } resp, err := c.client.Do(httpReq) if err != nil { @@ -228,6 +234,12 @@ func (c *OpenAIClient) StreamResponse(ctx context.Context, req PromptRequest, ha httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) httpReq.Header.Set("Accept", "text/event-stream") httpReq.Header.Set("Cache-Control", "no-cache") + if strings.Contains(c.baseURL, "openrouter") { + httpReq.Header.Set("HTTP-Referer", "https://pizenlabs.github.io/izen") + httpReq.Header.Set("X-Title", "izen") + httpReq.Header.Set("X-Description", "AI amplifies human judgment. Humans remain in control.") + httpReq.Header.Set("X-Categories", "cli-agent") + } resp, err := c.client.Do(httpReq) if err != nil { diff --git a/internal/llm/registry.go b/internal/llm/registry.go index 218bd56..a1917b1 100644 --- a/internal/llm/registry.go +++ b/internal/llm/registry.go @@ -146,6 +146,10 @@ func fetchOpenRouterModels(client *http.Client, apiKey string) ([]ModelInfo, err return nil, err } req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("HTTP-Referer", "https://pizenlabs.github.io/izen") + req.Header.Set("X-Title", "izen") + req.Header.Set("X-Description", "AI amplifies human judgment. Humans remain in control.") + req.Header.Set("X-Categories", "cli-agent") resp, err := client.Do(req) if err != nil { diff --git a/internal/modes/investigate/engine.go b/internal/modes/investigate/engine.go index b970531..acb3021 100644 --- a/internal/modes/investigate/engine.go +++ b/internal/modes/investigate/engine.go @@ -142,6 +142,34 @@ func (e *Engine) RunContext(ctx context.Context) (*InvestigationResult, error) { Problem: e.Problem, } + // ── DEADLOCK PREVENTION: NON-BUG INTENT SHORT-CIRCUIT ────────────── + // /investigate is STRICTLY read-only for bug diagnostics. When the + // intent is FeatureUnitTest, Refactor, or any code creation/mutation + // (detected by mutation verbs in the problem text), exit immediately + // with a handoff signal instead of looping through the forensic state + // machine. This prevents the infamous "investigate deadlock" where the + // engine keeps looping over test output for a task that requires writing + // code, not diagnosing bugs. + // + // The short-circuit produces: + // - No forensic evidence (no test execution, no LX lookups) + // - A clear conclusion: "code mutation intent detected — hand off to build" + // - Resolved=false so the caller knows investigation was not the right mode + if e.mustShortCircuitToBuild() { + forensicLog("[deadlock-guard] non-bug intent detected — short-circuiting /investigate → /build handoff") + result.Resolved = false + result.Conclusion = "code mutation intent detected — hand off to build" + result.RootCause = "" + result.Loops = 0 + result.Duration = time.Since(e.startedAt).Round(time.Millisecond).String() + e.Ledger.Conclusion = result.Conclusion + e.Ledger.SetRootCause("") + // Override diagnostics with a clear signal to the plan/build handoff. + e.Ledger.SetDiagnostics(result.Conclusion) + e.Result = result + return result, nil + } + for !e.State.ShouldStop() { select { case <-ctx.Done(): @@ -738,6 +766,41 @@ func (e *Engine) statePropose() error { return e.State.Transition(StateDone) } +// mutationIntentKeywords are phrases that clearly indicate the user wants to +// create or modify code — not diagnose a bug. When detected in the problem text, +// the investigate engine short-circuits to avoid the deadlock loop. +var mutationIntentKeywords = []string{ + "write test", "unit test", "test case", "test suite", + "add test", "create test", "implement test", + "add feature", "new feature", "implement feature", + "write code", "generate code", "stub", "mock", + "implement", "refactor", "restructure", "reorganize", + "add function", "create function", "add method", "create method", + "write function", +} + +// mustShortCircuitToBuild returns true when the investigation should exit +// immediately and hand off to /build instead of running the forensic state +// machine. This prevents the "investigate deadlock" on code creation intents. +// +// Detection rules: +// 1. Explicit intent set to FeatureUnitTest or Refactor → short-circuit. +// 2. Problem text contains mutation intent keywords → short-circuit. +// 3. Any $hot prefix → short-circuit (already handled at gateway, but +// double-check here as a safety net). +func (e *Engine) mustShortCircuitToBuild() bool { + if !e.Intent.IsEnvDepsAllowed() { + return true + } + lower := strings.ToLower(e.Problem) + for _, kw := range mutationIntentKeywords { + if strings.Contains(lower, kw) { + return true + } + } + return strings.HasPrefix(strings.TrimSpace(e.Problem), "$hot") +} + // deriveRootCause extracts a root cause description from the investigation result. // It synthesizes evidence and targets into a concise root cause statement. // This is the ONLY structural mutation /investigate performs — atomic task diff --git a/internal/modes/plan/engine.go b/internal/modes/plan/engine.go index f6145a0..3a64913 100644 --- a/internal/modes/plan/engine.go +++ b/internal/modes/plan/engine.go @@ -289,14 +289,18 @@ func (e *Engine) processFromLedger(ctx context.Context, ledgerContent string, pr MaxTokens: 500, } } else { - // ── DIRECT MUTATION ZERO-PROSE PROMPT ───────────── + // ── COMPLEXITY-CONDITIONAL SYSTEM PROMPT ────────── + // Assess heuristic complexity from the problem text. LOW and MEDIUM + // complexity tasks (standard code changes, config edits, unit tests) + // receive the compact 3-bullet checklist prompt — no verbose Senior + // Architect analysis. HIGH complexity or explicit high-intent flags + // get the full prose treatment. // When the problem/ledger indicates a direct file mutation // (refactor, convert, replace, etc.), use the zero-prose - // system prompt that skips all Senior Architect analysis - // sections (no CONTEXT & ROLE, no FORENSIC HANDOFF VECTOR). - // Forces the LLM to output only the direct task item. + // system prompt that skips all analysis entirely. isDirectMut := detectDirectMutation(problem, ledgerContent) != nil - systemPrompt := prompt.PlanSystemPrompt() + "\n\n" + SchemaJSONInstruction() + hasHighFlag := strings.Contains(problem, "--high") || strings.Contains(problem, "/intent high") + systemPrompt := prompt.SelectPlanSystemPrompt(problem, hasHighFlag) + "\n\n" + SchemaJSONInstruction() if isDirectMut { systemPrompt = prompt.PlanDirectMutationSystemPrompt() } @@ -1038,13 +1042,14 @@ func (e *Engine) ProcessPlan(ctx context.Context, modelName string, objective st } isDirectMut := detectDirectMutation(objective, "") != nil + hasHighFlag := strings.Contains(objective, "--high") || strings.Contains(objective, "/intent high") req := ai.Request{ Model: modelName, Messages: []ai.Message{ { Role: "system", - Content: prompt.PlanSystemPrompt() + "\n\n" + SchemaJSONInstruction(), + Content: prompt.SelectPlanSystemPrompt(objective, hasHighFlag) + "\n\n" + SchemaJSONInstruction(), }, { Role: "user", diff --git a/internal/prompt/plan.go b/internal/prompt/plan.go index 0b92b4a..f3378d1 100644 --- a/internal/prompt/plan.go +++ b/internal/prompt/plan.go @@ -3,6 +3,7 @@ package prompt import ( "fmt" "runtime" + "strings" ) // EnvironmentContext returns a compact, authoritative host runtime statement @@ -40,10 +41,74 @@ func osPackageManager(os string) string { } } -// PlanContract defines the behavioral contract for /plan mode. -// Phase 2 (Lightweight Execution Mapper): reads the compact Forensic Ledger -// from /investigate and maps it directly to structural atomic_tasks. No -// re-analysis, no conversational filler. +// ComplexityThreshold defines the boundary between compact and verbose plan prose. +// Tasks scoring > HighComplexityThreshold receive the full Senior Architect treatment. +const ( + LowComplexityThreshold = 4 + MediumComplexityThreshold = 7 + HighComplexityThreshold = 8 +) + +// ComplexityScore rates a planning task from 1 (trivial) to 10 (architectural). +type ComplexityScore int + +const ( + ComplexityTrivial ComplexityScore = 1 + ComplexitySimple ComplexityScore = 3 + ComplexityModerate ComplexityScore = 5 + ComplexityComplex ComplexityScore = 7 + ComplexityArchitectural ComplexityScore = 9 +) + +// IsHighComplexity returns true when the score exceeds the threshold or the +// user explicitly requested a high-intent analysis via --high or /intent high. +func IsHighComplexity(score ComplexityScore, hasHighFlag bool) bool { + if hasHighFlag { + return true + } + return score > HighComplexityThreshold +} + +// AssessComplexity assigns a heuristic complexity score based on task keywords. +// Scoring: +// +// 1-3: simple config/doc edits (LICENSE, README, formatting) +// 4-6: moderate code changes (add tests, small refactors, single-file edits) +// 7-8: multi-file refactors, API changes +// 9-10: architectural changes, cross-cutting concerns, migrations +func AssessComplexity(objective string) ComplexityScore { + lower := strings.ToLower(objective) + + highComplexityKeywords := []string{ + "migration", "architect", "redesign", "restructure", + "cross-cutting", "concurrency", "distributed", + "database", "schema", "api design", "protocol", + "security", "authentication", "authorization", + "pipeline", "event-driven", "message queue", + } + lowComplexityKeywords := []string{ + "license", "readme", "typo", "comment", "format", + "rename", "spelling", "grammar", "whitespace", + "capitalize", "config", "version bump", + } + + for _, kw := range highComplexityKeywords { + if strings.Contains(lower, kw) { + return ComplexityComplex + } + } + for _, kw := range lowComplexityKeywords { + if strings.Contains(lower, kw) { + return ComplexitySimple + } + } + + return ComplexityModerate +} + +// PlanContract returns the verbose Senior Principal Structural Architect contract. +// Used ONLY when the task complexity exceeds HighComplexityThreshold or the user +// explicitly opted into high-intent analysis. func PlanContract() string { return `MODE: /plan — Structural Architecture Synthesis @@ -81,6 +146,41 @@ RULES - Use native Go tooling first (` + "`go get`" + `, ` + "`go mod tidy`" + `, ` + "`go install`" + `). Never default to ` + "`brew install`" + ` or ` + "`docker`" + `.` } +// CompactPlanContract returns a stripped-down 3-bullet checklist contract for +// LOW and MEDIUM complexity tasks. Omits ROLE, protocol details, and verbose +// analysis instructions. Used when IsHighComplexity is false. +func CompactPlanContract() string { + return `MODE: /plan — Quick Task Checklist + +ROLE: Execution Mapper. Map the objective to a compact task list. + +PROTOCOL +- Read the objective. Identify the file(s) to modify. +- Output a 3-bullet checklist: (1) prep/setup, (2) the change itself, (3) verification. +- Every SHELL_EXEC must be a real runnable command. +- Output ONLY raw task blocks. No preamble, no analysis, no commentary. + +OUTPUT FORMAT: +- [ ] SHELL_EXEC: | +- [ ] FILE_MUTATE: | +- [ ] SHELL_EXEC: | verify + +RULES +- Missing Go dependency → SHELL_EXEC: go get | install missing dependency. +- FORBIDDEN: "go.mod", "go.sum", relative paths as shell target. +- No brew, docker, or OS-level tasks. Stay at the code/dependency boundary.` +} + +// SelectPlanContract returns the appropriate plan contract based on complexity. +// High-complexity tasks or explicit high-intent requests get the full Senior +// Architect prose; everything else gets the compact 3-bullet checklist. +func SelectPlanContract(objective string, complexity ComplexityScore, hasHighFlag bool) string { + if IsHighComplexity(complexity, hasHighFlag) { + return PlanContract() + } + return CompactPlanContract() +} + // planDirectives are the shared DIRECTIVES rules for BuildPlanJSONPrompt. // Extracted to eliminate copy-paste between the isDirectMutation branches. const planDirectives = `DIRECTIVES diff --git a/internal/prompt/registry.go b/internal/prompt/registry.go index 624a32a..ff23681 100644 --- a/internal/prompt/registry.go +++ b/internal/prompt/registry.go @@ -77,6 +77,25 @@ func PlanSystemPrompt() string { return Compose(PlanContract(), RuntimeFacts{HostOS: runtime.GOOS}) } +// CompactPlanSystemPrompt returns the compact 3-bullet checklist prompt for +// LOW and MEDIUM complexity tasks. Omits verbose Senior Architect prose. +func CompactPlanSystemPrompt() string { + return Compose(CompactPlanContract(), RuntimeFacts{HostOS: runtime.GOOS}) +} + +// SelectPlanSystemPrompt returns the appropriate plan system prompt based on +// the task objective's heuristic complexity and the presence of an explicit +// high-intent flag. High-complexity tasks (>8/10) or explicit --high flags +// get the full Senior Architect treatment; everything else gets the compact +// 3-bullet checklist format. +func SelectPlanSystemPrompt(objective string, hasHighFlag bool) string { + complexity := AssessComplexity(objective) + if IsHighComplexity(complexity, hasHighFlag) { + return PlanSystemPrompt() + } + return CompactPlanSystemPrompt() +} + // InvestigateSystemPrompt returns the composed system prompt for investigate mode. func InvestigateSystemPrompt() string { return Compose(InvestigateContract(), RuntimeFacts{HostOS: runtime.GOOS}) diff --git a/internal/providers/openrouter.go b/internal/providers/openrouter.go index 8e9e564..6b7585d 100644 --- a/internal/providers/openrouter.go +++ b/internal/providers/openrouter.go @@ -69,6 +69,10 @@ func (p *OpenRouterProvider) Execute(ctx context.Context, req ai.Request) (*ai.R } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+p.apiKey) + httpReq.Header.Set("HTTP-Referer", "https://pizenlabs.github.io/izen") + httpReq.Header.Set("X-Title", "izen") + httpReq.Header.Set("X-Description", "AI amplifies human judgment. Humans remain in control.") + httpReq.Header.Set("X-Categories", "cli-agent") resp, err := p.client.Do(httpReq) if err != nil { @@ -139,6 +143,10 @@ func (p *OpenRouterProvider) ExecuteStream(ctx context.Context, req ai.Request) httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+p.apiKey) httpReq.Header.Set("Accept", "text/event-stream") + httpReq.Header.Set("HTTP-Referer", "https://pizenlabs.github.io/izen") + httpReq.Header.Set("X-Title", "izen") + httpReq.Header.Set("X-Description", "AI amplifies human judgment. Humans remain in control.") + httpReq.Header.Set("X-Categories", "cli-agent") resp, err := p.client.Do(httpReq) if err != nil { diff --git a/internal/ui/commands.go b/internal/ui/commands.go index c8ba0aa..bd9ad8f 100644 --- a/internal/ui/commands.go +++ b/internal/ui/commands.go @@ -451,6 +451,15 @@ func (m *model) handleMessageContent(line string) tea.Cmd { content = fileCtx.String() + "\n\n" + content } + // ── $hot FAST-TRACK ───────────────────────────────────────────────── + // Any message starting with $hot bypasses ALL plan generation and + // diagnostic loops, routing directly to the /build engine for instant + // execution. Also strip the $hot prefix before passing to build. + if strings.HasPrefix(strings.TrimSpace(content), "$hot") { + hotContent := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(content), "$hot")) + return m.runBuildCmd(hotContent) + } + if m.resolver.Current() == modes.ModeBuild && m.graph != nil { compressor := retrieval.NewContextCompressorFromGraph(m.graph, m.sess.ObjectiveIntent()) compressed := compressor.CompressLines(content) @@ -463,7 +472,20 @@ func (m *model) handleMessageContent(line string) tea.Cmd { go retrieval.BuildGlobalCompressor(g, m.sess.ObjectiveIntent()) } - switch m.resolver.Current() { + // ── INTENT-BASED MODE OVERRIDE ────────────────────────────────────── + // When the current mode is investigate but the user intent involves code + // creation/mutation (not bug diagnostics), override to /build directly. + // This prevents the investigate deadlock where the engine loops over + // forensic evidence for a task that requires writing code. + currentMode := m.resolver.Current() + if currentMode == modes.ModeInvestigate && len(refFiles) > 0 { + if hasMutationIntent(content) { + m.push(roleStatus, "Code mutation intent detected — routing directly to /build, bypassing /investigate") + return m.runBuildCmd(content) + } + } + + switch currentMode { case modes.ModeInvestigate: if m.investigateInvocationCount >= maxInvestigateInvocations { m.push(roleError, fmt.Sprintf("max investigate invocations (%d) reached", maxInvestigateInvocations)) @@ -4773,6 +4795,39 @@ func isHandoffNoiseLine(s string) bool { // the start of a line — raw diagnostic residue, never an actionable task. var compilerCoordRe = regexp.MustCompile(`^[^\s:]+\.\w+:\d+:\d+`) +// hasMutationIntent reports whether the given content clearly describes a code +// creation or mutation task (as opposed to a bug diagnosis or investigation). +// Used to override /investigate mode routing and prevent the deadlock loop. +func hasMutationIntent(content string) bool { + lower := strings.ToLower(content) + mutationSignals := []string{ + "write", "create", "generate", "implement", + "add ", "make ", "build ", "develop", + "update ", "modify ", "change ", "refactor ", + "fix ", "correct ", "edit ", + "test", "spec", "stub", "mock", + } + diagnosticSignals := []string{ + "why is", "why does", "what caused", "investigate", + "is broken", "is crashing", "is failing", + "stack trace", "backtrace", "root cause", + "crash", "panic", "bug", + } + // If diagnostic signals are present, this is NOT a mutation. + for _, s := range diagnosticSignals { + if strings.Contains(lower, s) { + return false + } + } + // Check for mutation signals. + for _, s := range mutationSignals { + if strings.Contains(lower, s) { + return true + } + } + return false +} + // extractTodosFromPlan extracts TODO items from a plan-mode LLM response. func extractTodosFromPlan(content string) []string { tasks := plan.ParseMarkdownToTasks(content) diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 53fe6e1..f532da6 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -21,6 +21,22 @@ func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } + // ── GLOBAL: Ctrl+O toggles the most recent foldable log entry ── + if msg.Type == tea.KeyCtrlO { + if m.logStore != nil { + entries := m.logStore.Entries() + if len(entries) > 0 { + last := entries[len(entries)-1] + m.logStore.Toggle(last.ID) + m.refreshViewportContent() + if m.Ready && !m.userIsScrollingUp { + m.Viewport.GotoBottom() + } + } + } + return m, nil + } + // ── GLOBAL: Alt+F / Option+F / Meta+F — Handoff from /ask to /investigate ── // Checks the latest valid /ask Context Ledger (ask_handoff packet), and if // present, transitions to /investigate with the ledger injected as context. diff --git a/internal/ui/logs.go b/internal/ui/logs.go new file mode 100644 index 0000000..6694385 --- /dev/null +++ b/internal/ui/logs.go @@ -0,0 +1,141 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// LogEntryKind is the type of execution log action. +type LogEntryKind int + +const ( + LogCreate LogEntryKind = iota + LogEdit + LogBash + LogSearch + LogDelete + LogOther +) + +func (k LogEntryKind) String() string { + switch k { + case LogCreate: + return "Create" + case LogEdit: + return "Edit" + case LogBash: + return "Bash" + case LogSearch: + return "Search" + case LogDelete: + return "Delete" + default: + return "Action" + } +} + +// LogEntry represents a single foldable execution log entry. +type LogEntry struct { + ID int + Kind LogEntryKind + Target string // file path or command + Success bool + Content string // full diff/patch/stdout (shown when expanded) + Expanded bool +} + +// LogStore holds a collection of foldable log entries. +type LogStore struct { + entries []LogEntry + nextID int +} + +func NewLogStore() *LogStore { + return &LogStore{ + entries: make([]LogEntry, 0), + nextID: 1, + } +} + +// Add appends a new log entry and returns its ID. +func (ls *LogStore) Add(kind LogEntryKind, target string, success bool, content string) int { + id := ls.nextID + ls.nextID++ + ls.entries = append(ls.entries, LogEntry{ + ID: id, + Kind: kind, + Target: target, + Success: success, + Content: content, + Expanded: false, + }) + return id +} + +// Toggle toggles the expanded state of the entry with the given ID. +// Returns true if the entry was found. +func (ls *LogStore) Toggle(id int) bool { + for i := range ls.entries { + if ls.entries[i].ID == id { + ls.entries[i].Expanded = !ls.entries[i].Expanded + return true + } + } + return false +} + +// Entries returns all log entries. +func (ls *LogStore) Entries() []LogEntry { + return ls.entries +} + +// RenderEntry renders a single log entry. +// Collapsed: 🟢 ✓ Edit(path/to/file.go) (ctrl+o to expand) +// Expanded: shows full content below the summary line. +func RenderEntry(entry LogEntry, width int) string { + icon := greenStyle.Render("✓") + if !entry.Success { + icon = redStyle.Render("✗") + } + + label := entry.Kind.String() + target := entry.Target + if len(target) > 40 { + target = "..." + target[len(target)-37:] + } + + summary := fmt.Sprintf("%s %s(%s)", icon, label, target) + if !entry.Expanded { + hint := dimmedStyle.Render(" (ctrl+o to expand)") + return lipgloss.NewStyle().Width(width).Render(summary + hint) + } + + var b strings.Builder + b.WriteString(summary) + b.WriteString("\n") + if entry.Content != "" { + lines := strings.Split(entry.Content, "\n") + for _, line := range lines { + b.WriteString(" ") + b.WriteString(mutedStyle.Render(line)) + b.WriteString("\n") + } + } + b.WriteString(dimmedStyle.Render(" (ctrl+o to collapse)")) + return b.String() +} + +// RenderLogSummary renders all collapsed log entries as compact bullet list. +func RenderLogSummary(entries []LogEntry, width int) string { + if len(entries) == 0 { + return "" + } + var b strings.Builder + for _, entry := range entries { + b.WriteString(RenderEntry(entry, width)) + b.WriteString("\n") + } + return strings.TrimSuffix(b.String(), "\n") +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 047cb27..fb91e7c 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -844,6 +844,9 @@ type model struct { showModelPicker bool modelPicker *ModelPickerModal sessionModel string // user-selected model override via /model + + // Foldable execution logs + logStore *LogStore } // isProjectInitialized checks whether .izen/ exists AND contains a valid @@ -1339,6 +1342,21 @@ func (m *model) refreshViewportContent() { content.WriteString(m.PreRenderedHistory) } + // ── Foldable execution log entries ───────────────────────────── + if m.logStore != nil { + entries := m.logStore.Entries() + if len(entries) > 0 { + content.WriteString("\n") + content.WriteString(dimmedStyle.Render("── Execution Log ──")) + content.WriteString("\n") + for _, entry := range entries { + rendered := RenderEntry(entry, m.width) + content.WriteString(rendered) + content.WriteString("\n") + } + } + } + if m.streaming { if m.currentStreamContent != "" { rendered := m.renderStreamingContent(m.currentStreamContent, m.width) diff --git a/internal/ui/model_picker.go b/internal/ui/model_picker.go index 7dd9fbe..4a5abbd 100644 --- a/internal/ui/model_picker.go +++ b/internal/ui/model_picker.go @@ -12,6 +12,73 @@ import ( "github.com/PizenLabs/izen/internal/llm" ) +// EffortLevel represents the intent/effort slider value. +type EffortLevel int + +const ( + EffortLow EffortLevel = iota + EffortMedium + EffortHigh +) + +func (e EffortLevel) String() string { + switch e { + case EffortLow: + return "low" + case EffortMedium: + return "medium" + case EffortHigh: + return "high" + default: + return "low" + } +} + +// Description returns a human-readable description of the effort level. +func (e EffortLevel) Description() string { + switch e { + case EffortLow: + return "Fast-Track / Direct Mutation" + case EffortMedium: + return "Hybrid Mutation + Local Templates" + case EffortHigh: + return "Full Senior Architect Mode" + default: + return "Fast-Track / Direct Mutation" + } +} + +// ConfigTier maps effort level to the config tier key. +func (e EffortLevel) ConfigTier() string { + switch e { + case EffortLow: + return "low_intent" + case EffortMedium: + return "medium_intent" + case EffortHigh: + return "high_intent" + default: + return "low_intent" + } +} + +// Style returns the pre-compiled render-path style for this effort level: +// low is green (fast/low-risk), medium is yellow (moderate), high is red +// (heaviest/highest-risk mode) — reusing the shared Catppuccin styles from +// styles.go rather than declaring new ones. +func (e EffortLevel) Style() lipgloss.Style { + switch e { + case EffortLow: + return greenStyle + case EffortMedium: + return yellowStyle + case EffortHigh: + return redStyle + default: + return greenStyle + } +} + type modelPickerState int const ( @@ -20,20 +87,42 @@ const ( mpErr ) -// modelListLineBudget is the fixed number of lines the scrollable model -// list body occupies, regardless of how many provider headers/separators -// land inside the visible window. Keeping this constant — and keeping the -// list windowed/padded in terms of *rendered rows* rather than filtered -// items — is what makes renderList()'s total output height perfectly -// constant across every render (cursor movement, filtering, refresh). +// modelListLineBudget is the *default* number of lines the scrollable +// model list body occupies when no terminal size is known yet (i.e. +// before the first SetSize call). Once SetSize has run, the real budget +// is computed per-render by listRowBudget(), which shrinks or grows the +// list to fit the space actually available — this is what keeps the +// modal from being clipped when the user is running Izen in a narrow +// tmux/terminal split pane. // -// Because mpView's height never changes, the outer modal box in -// workspace.go (renderModelPickerModal) simply auto-sizes around it -// instead of hardcoding its own Height/MaxHeight — removing the need to -// keep any cross-file height arithmetic in sync. Change this number -// freely; it only affects how many rows are visible at once. +// Regardless of which value is in play, the list stays windowed/padded +// in terms of *rendered rows* rather than filtered items, so renderList()'s +// total output height is still perfectly constant for any given terminal +// size (cursor movement, filtering, refresh never change it) — only a +// resize event changes it. That's what lets the outer modal box in +// workspace.go (renderModelPickerModal) auto-size around it instead of +// hardcoding its own Height/MaxHeight. const modelListLineBudget = 7 +// modelListMinRows is the smallest the scrollable list body is ever +// allowed to shrink to, even in a very short split pane. Below this the +// picker stops being usable, so we'd rather show a cramped-but-functional +// list than one that's silently cut off. +const modelListMinRows = 3 + +// modelPickerChromeLines is the number of fixed, non-list-body lines +// renderList() always emits: title + blank, search input, count/refresh +// line + blank, effort description + effort track + blank, the gap before +// the footer, and the footer itself — plus the outer border (2) and +// Padding(1, 3) (2). Kept in sync with renderList(); see the comment +// there if either changes. +const modelPickerChromeLines = 8 + 2 + 2 + 2 + +// modelPickerMinWidth/Height are floors applied in SetSize so a very +// aggressively split pane doesn't hand us a zero or negative size. +const modelPickerMinWidth = 28 +const modelPickerMinHeight = modelPickerChromeLines + modelListMinRows + type ModelPickerModal struct { ti textinput.Model state modelPickerState @@ -46,9 +135,23 @@ type ModelPickerModal struct { height int registry *llm.ModelRegistry + effortIdx int // 0=low, 1=medium, 2=high scrollOffset int // row-based offset into buildRows(), NOT an item index } +func (mp *ModelPickerModal) CurrentEffort() EffortLevel { + switch mp.effortIdx { + case 0: + return EffortLow + case 1: + return EffortMedium + case 2: + return EffortHigh + default: + return EffortLow + } +} + type modelPickerLoadedMsg struct { models []llm.ModelInfo err error @@ -101,7 +204,8 @@ func (mp *ModelPickerModal) RefreshModels(providers map[string]string) tea.Cmd { } type modelSelectedMsg struct { - model llm.ModelInfo + model llm.ModelInfo + effort EffortLevel } func (mp *ModelPickerModal) Update(msg tea.Msg) (*ModelPickerModal, tea.Cmd) { @@ -149,11 +253,24 @@ func (mp *ModelPickerModal) Update(msg tea.Msg) (*ModelPickerModal, tea.Cmd) { mp.clampScrollOffset() return mp, nil + case tea.KeyLeft: + if mp.effortIdx > 0 { + mp.effortIdx-- + } + return mp, nil + + case tea.KeyRight: + if mp.effortIdx < 2 { + mp.effortIdx++ + } + return mp, nil + case tea.KeyEnter: if mp.cursor >= 0 && mp.cursor < len(mp.filtered) { selected := mp.filtered[mp.cursor] + effort := mp.CurrentEffort() return mp, func() tea.Msg { - return modelSelectedMsg{model: selected} + return modelSelectedMsg{model: selected, effort: effort} } } return mp, nil @@ -176,9 +293,41 @@ func (mp *ModelPickerModal) Update(msg tea.Msg) (*ModelPickerModal, tea.Cmd) { } func (mp *ModelPickerModal) SetSize(w, h int) { + if w < modelPickerMinWidth { + w = modelPickerMinWidth + } + if h < modelPickerMinHeight { + h = modelPickerMinHeight + } mp.width = w mp.height = h - mp.ti.Width = w - 12 + + tiWidth := w - 12 + if tiWidth < 10 { + tiWidth = 10 + } + mp.ti.Width = tiWidth + + // Re-clamp the current scroll position: shrinking the pane can put + // the previous offset past the new (smaller) row budget. + mp.clampScrollOffset() +} + +// listRowBudget returns how many rows the scrollable list body should +// render this frame. It's derived from the last known terminal size +// (via SetSize) minus the fixed chrome around it, floored at +// modelListMinRows so the list never fully disappears in a tiny pane, +// and falls back to the static modelListLineBudget default before the +// first SetSize call has happened (e.g. mid-startup). +func (mp *ModelPickerModal) listRowBudget() int { + if mp.height <= 0 { + return modelListLineBudget + } + budget := mp.height - modelPickerChromeLines + if budget < modelListMinRows { + budget = modelListMinRows + } + return budget } // ── Row model ──────────────────────────────────────────────────────────── @@ -248,15 +397,16 @@ func (mp *ModelPickerModal) clampScrollOffset() { } cursorRow := rowIndexForItem(rows, mp.cursor) + budget := mp.listRowBudget() // Keep the cursor's row inside the visible window. if cursorRow < mp.scrollOffset { mp.scrollOffset = cursorRow - } else if cursorRow >= mp.scrollOffset+modelListLineBudget { - mp.scrollOffset = cursorRow - modelListLineBudget + 1 + } else if cursorRow >= mp.scrollOffset+budget { + mp.scrollOffset = cursorRow - budget + 1 } - maxOffset := total - modelListLineBudget + maxOffset := total - budget if maxOffset < 0 { maxOffset = 0 } @@ -332,7 +482,7 @@ func (mp *ModelPickerModal) renderList() string { Foreground(lipgloss.Color(colorMauve)). Render(" Model Picker ") b.WriteString(title) - b.WriteString("\n") + b.WriteString("\n\n") // ── Search bar ────────────────────────────────────────────────────── b.WriteString(mp.ti.View()) @@ -348,7 +498,16 @@ func (mp *ModelPickerModal) renderList() string { b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Faint(true).Render("Ctrl+R refresh")) b.WriteString("\n\n") - // ── Fixed-height, row-based scrolling list ────────────────────────── + // ── Effort / Intent Slider ─────────────────────────────────────────── + effortRow := mp.renderEffortSlider() + b.WriteString(effortRow) + b.WriteString("\n\n") + + // ── Resizable, row-based scrolling list ───────────────────────────── + // budget is recomputed from the last known terminal size (see + // listRowBudget) rather than a hardcoded constant, so a narrow/short + // tmux split pane shrinks the list instead of clipping the modal. + budget := mp.listRowBudget() rows := mp.buildRows() total := len(rows) @@ -358,7 +517,7 @@ func (mp *ModelPickerModal) renderList() string { if mp.scrollOffset < 0 { mp.scrollOffset = 0 } - end := mp.scrollOffset + modelListLineBudget + end := mp.scrollOffset + budget if end > total { end = total } @@ -396,19 +555,26 @@ func (mp *ModelPickerModal) renderList() string { Foreground(lipgloss.Color(colorAccent)). Bold(true) } - fmt.Fprintf(&b, "%s%s", cursor, itemStyle.Render(m.ID)) + // Truncate long model IDs in narrow panes instead of letting + // them wrap and blow out the fixed row budget. + maxIDWidth := mp.width - 12 + id := truncateWithEllipsis(m.ID, maxIDWidth) + fmt.Fprintf(&b, "%s%s", cursor, itemStyle.Render(id)) b.WriteString("\n") } } // Pad blank lines so the body — and therefore the whole modal — never - // changes height, no matter how many header/blank rows were in view. - for i := len(window); i < modelListLineBudget; i++ { + // changes height for a given terminal size, no matter how many + // header/blank rows were in view. (It *does* change across a resize, + // since budget itself is resize-driven — that's the point.) + for i := len(window); i < budget; i++ { b.WriteString("\n") } + b.WriteString("\n") // ── Footer ────────────────────────────────────────────────────────── - footer := mutedStyle.Render("↑↓ navigate ↵ select Esc close") + footer := mutedStyle.Render("↑↓ navigate ↵ select ←→ effort Esc close") b.WriteString(footer) borderColor := lipgloss.Color(colorMauve) @@ -418,10 +584,77 @@ func (mp *ModelPickerModal) renderList() string { Width(mp.width-4). Border(lipgloss.RoundedBorder()). BorderForeground(borderColor). - Padding(1, 2). + Padding(1, 3). Render(content) } +// renderEffortSlider renders the interactive effort/intent slider. +// Visual layout: low ───○─── medium ────── high +func (mp *ModelPickerModal) renderEffortSlider() string { + levels := []struct { + label string + desc string + }{ + {"low", "Fast-Track"}, + {"medium", "Hybrid"}, + {"high", "Senior Arch"}, + } + + trackLen := 6 + var b strings.Builder + + // Description line — tinted to match the selected level, so the + // color reads as "how heavy is this effort" at a glance. + effort := mp.CurrentEffort() + levelStyle := effort.Style() + desc := levelStyle.Render(effort.Description()) + b.WriteString(" " + mutedStyle.Render("Effort:") + " " + desc + "\n") + + // Slider track + b.WriteString(" ") + for i, lvl := range levels { + if i == mp.effortIdx { + b.WriteString(levelStyle.Bold(true).Render(lvl.label)) + } else { + b.WriteString(dimmedStyle.Render(lvl.label)) + } + if i < len(levels)-1 { + b.WriteString(" ") + for j := 0; j < trackLen; j++ { + switch { + case i == mp.effortIdx && j == 0: + b.WriteString(levelStyle.Render("●")) + case i < mp.effortIdx && j == trackLen-1: + b.WriteString(levelStyle.Render("●")) + default: + b.WriteString(dimmedStyle.Render("─")) + } + } + b.WriteString(" ") + } + } + + return b.String() +} + +// truncateWithEllipsis shortens s to fit within max runes, replacing the +// tail with "…" when it doesn't fit. Operates on the raw (unstyled) +// string, so callers should truncate before applying lipgloss styling. +// A non-positive max is treated as "no room" and returns "". +func truncateWithEllipsis(s string, max int) string { + if max <= 0 { + return "" + } + runes := []rune(s) + if len(runes) <= max { + return s + } + if max == 1 { + return "…" + } + return string(runes[:max-1]) + "…" +} + func providerAuthStatus(provider string) string { if provider == "ollama" { return "" diff --git a/internal/ui/program.go b/internal/ui/program.go index 56844b3..974a5b8 100644 --- a/internal/ui/program.go +++ b/internal/ui/program.go @@ -175,6 +175,7 @@ func NewProgram(root string, cfg *config.Config, sess *session.Session, mgr *ai. initPrefillUsername: globalUsername, initPrefillProvider: globalProvider, viewRegistry: reg, + logStore: NewLogStore(), } if initStage == initIdentity { m.initIdentityInput = textinput.New() diff --git a/internal/ui/ui_simulation_test.go b/internal/ui/ui_simulation_test.go index 2ae0515..aae2c7b 100644 --- a/internal/ui/ui_simulation_test.go +++ b/internal/ui/ui_simulation_test.go @@ -48,6 +48,7 @@ func newTestModel() *model { state: StateAwaitingApproval, Ready: true, Viewport: vp, + logStore: NewLogStore(), pendingProposals: []SemanticProposal{ { ID: "test-1", diff --git a/internal/ui/update.go b/internal/ui/update.go index 7736cb0..6f8d5b4 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -175,9 +175,15 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height m.ti.Width = msg.Width - 8 - if m.showModelPicker && m.modelPicker != nil { - m.modelPicker.SetSize(msg.Width, msg.Height) - } + // NOTE: the model picker's own size is NOT set here. It's derived + // from m.width/m.height (just updated above) by + // modelPickerDialogSize() and applied in renderModelPickerModal() + // on every render — that's what lets it track resizes precisely + // and shrink to fit a narrow tmux/terminal split instead of + // overflowing it. A SetSize call here used to pass the *full* + // terminal size (not the dialog's actual on-screen size) and was + // immediately superseded by renderModelPickerModal's own call on + // the next View() anyway, so it did nothing but mislead. vpHeight := m.computeVpHeight() @@ -364,18 +370,24 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(msg.records) == 0 { m.push(roleSystem, "Investigation complete — no structured findings to report.") } - m.push(roleStatus, "Investigation complete. Auto-transitioning to /plan for execution synthesis...") - // ── AUTO-HANDOFF: /investigate -> /plan ──────────────────────────── - // Once investigation completes with a valid Context Ledger, automatically - // transition to /plan to synthesize the structured execution timeline. - // The plan will be rendered for user review (Approve, Edit, Add/Remove, - // Reject) — automatic execution without human approval is strictly forbidden. - m.handoffCtx.ProposedFix = m.handoffLedgerContent + // ── AUTO-HANDOFF: /investigate -> /build (mutation) or /plan (diagnostic) ── + // When the investigate engine short-circuited with a code mutation intent + // ("code mutation intent detected — hand off to build"), route directly to + // /build to skip the plan synthesis step entirely. For bug diagnostics, + // route to /plan as normal. var cmds []tea.Cmd - if m.handoffLedgerContent != "" { + if strings.Contains(m.handoffLedgerContent, "code mutation intent detected") { + m.push(roleStatus, "Code mutation intent — routing directly to /build, bypassing /plan") m.modeChangeAuthorized = true - cmds = append(cmds, m.setMode(modes.ModePlan)) + cmds = append(cmds, m.setMode(modes.ModeBuild)) + } else { + m.push(roleStatus, "Investigation complete. Auto-transitioning to /plan for execution synthesis...") + m.handoffCtx.ProposedFix = m.handoffLedgerContent + if m.handoffLedgerContent != "" { + m.modeChangeAuthorized = true + cmds = append(cmds, m.setMode(modes.ModePlan)) + } } m.refreshViewportContent() m.Viewport.GotoBottom() @@ -1270,6 +1282,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { outcomeLine := fmt.Sprintf("%s %s • %s", successBannerStyle.Render("[✓]"), msg.file, msg.status) m.push(roleSystem, outcomeLine) m.createBuildCheckpoint(1) + + // Log foldable entry for the file mutation. + m.logStore.Add(LogEdit, msg.file, true, msg.status) // ── ADVANCE BUILD QUEUE ──────────────────────────────── // After a FILE_MUTATE/GIT_ACTION task completes, check for // the next idle task and execute it. @@ -1295,6 +1310,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } else { m.push(roleSystem, failureBannerStyle.Render("[✗] "+msg.file+" — "+msg.err.Error())) + m.logStore.Add(LogEdit, msg.file, false, msg.err.Error()) } } else { m.state = StateAwaitingApproval @@ -1313,6 +1329,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { for _, r := range msg.results { if r.err != nil { m.setApplyError("apply failed: " + r.err.Error()) + m.logStore.Add(LogEdit, r.file, false, r.err.Error()) failed++ continue } @@ -1320,6 +1337,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Target: r.file, Status: r.status, }) + m.logStore.Add(LogEdit, r.file, true, r.status) applied++ } m.pendingProposals = nil @@ -2165,6 +2183,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cfg.Models.SessionModel = msg.model.ID m.IsCloudModel = msg.model.Provider != "ollama" + // Apply effort/intent level to the config tiers. + effort := msg.effort + tierKey := effort.ConfigTier() + m.cfg.SetTierOverride(tierKey, msg.model.ID) + modelProvider := msg.model.Provider currentProvider := "" if m.provider != nil { @@ -2185,7 +2208,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.ti.Focus() + effortLabel := msg.effort.Description() m.push(roleSystem, accentStyle.Render(fmt.Sprintf("✓ Model set to %s [%s]", msg.model.Name, msg.model.Provider))) + m.push(roleSystem, mutedStyle.Render(fmt.Sprintf(" Effort: %s (%s)", msg.effort, effortLabel))) m.refreshViewportContent() m.Viewport.GotoBottom() return m, tea.Batch(cmds...) diff --git a/internal/ui/workspace.go b/internal/ui/workspace.go index 4cf3598..1314ee7 100644 --- a/internal/ui/workspace.go +++ b/internal/ui/workspace.go @@ -81,6 +81,56 @@ func (r *Registry) For(mode modes.Mode) (ViewMode, bool) { // it resolves UI lifecycle overlays (init / help / loading) and otherwise // delegates to the registered ViewMode for the current mode. The renderer // never sees mode, banner, prompt, footer, or action logic. +// modelPickerPreferredWidth/Height are the "comfortable" dialog size used +// whenever the terminal (or tmux/split pane) has room for it. +const modelPickerPreferredWidth = 68 +const modelPickerPreferredHeight = 18 + +// modelPickerEdgeMargin is the minimum gap kept between the modal's own +// border and the raw edge of the terminal/pane, so the border never sits +// flush against (or past) the boundary. +const modelPickerEdgeMargin = 2 + +// modelPickerDialogSize computes the size to hand to ModelPickerModal.SetSize +// for the *current* terminal dimensions (m.width/m.height, kept up to date by +// the tea.WindowSizeMsg handler in update.go). +// +// This used to be hardcoded as SetSize(68, 18) here, unconditionally, on +// every single render. That's what caused the modal to get its border +// sliced off in a narrow tmux/terminal split: no matter how small the pane +// actually was, this call re-stamped the picker's width/height back to a +// fixed 68x18 immediately before every View(), overwriting whatever +// correct, pane-aware size had just been computed. The picker's own +// internal resizing logic (ModelPickerModal.listRowBudget, etc.) never got +// a chance to run, because it was never told the real size in the first +// place. +// +// Now the preferred 68x18 is only used when it actually fits; otherwise we +// shrink to the available space, floored at the picker's own +// modelPickerMinWidth/modelPickerMinHeight so the two never disagree. +func (m *model) modelPickerDialogSize() (int, int) { + w := modelPickerPreferredWidth + h := modelPickerPreferredHeight + + if m.width > 0 { + if maxW := m.width - modelPickerEdgeMargin; maxW < w { + w = maxW + } + } + if m.height > 0 { + if maxH := m.height - modelPickerEdgeMargin; maxH < h { + h = maxH + } + } + if w < modelPickerMinWidth { + w = modelPickerMinWidth + } + if h < modelPickerMinHeight { + h = modelPickerMinHeight + } + return w, h +} + // renderModelPickerModal renders the model picker as a compact, centered // floating dialog over the normal workspace background. func (m *model) renderModelPickerModal() string { @@ -106,20 +156,27 @@ func (m *model) renderModelPickerModal() string { } normalContent := lipgloss.JoinVertical(lipgloss.Left, parts...) - // Set compact dimensions on the model picker so it renders at modal size. - m.modelPicker.SetSize(68, 18) + // Size the model picker to fit the *actual* terminal/pane, shrinking + // below the preferred 68x18 when there isn't room for it (see + // modelPickerDialogSize for why this must not be a hardcoded call). + dialogW, dialogH := m.modelPickerDialogSize() + m.modelPicker.SetSize(dialogW, dialogH) mpView := m.modelPicker.View() // Outer modal box. No hardcoded Height/MaxHeight here on purpose: mpView - // (renderList in model_picker.go) is a fixed height by construction — - // modelListLineBudget always pads its row list out to the same number - // of lines — so this box naturally renders at a constant size on every - // frame without needing cross-file height arithmetic kept in sync by - // hand. Hardcoding a Height here previously caused the bottom border - // to get silently clipped whenever the true content height drifted - // even by a line. + // (renderList in model_picker.go) is a fixed height for any given + // dialogH — ModelPickerModal.listRowBudget always pads its row list out + // to the same number of lines for that size — so this box naturally + // renders at a constant size for the current terminal without needing + // cross-file height arithmetic kept in sync by hand. Hardcoding a + // Height here previously caused the bottom border to get silently + // clipped whenever the true content height drifted even by a line. + // + // Width follows dialogW (+2 to match the picker's own inner + // border/padding math) instead of a fixed 70, so the outer box shrinks + // in step with the picker itself rather than overflowing the pane. modalBox := lipgloss.NewStyle(). - Width(70). + Width(dialogW+2). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(colorBlue)). Padding(0, 1).