From ba1cb9cf1a6b18ed6455d9d95e5431ad9c6e2ccf Mon Sep 17 00:00:00 2001 From: andev0x Date: Fri, 24 Jul 2026 12:00:15 +0700 Subject: [PATCH 1/5] feat(ui): add session checkpoint and commit cmd - add diff range and reset soft functionality - enhance commit generation with consecutive build checkpoint squashing --- internal/execution/checkpoint.go | 4 ++ internal/git/engine.go | 44 +++++++++++++++ internal/ui/agents.go | 94 +++++++++++++++++++++++++------- internal/ui/commands.go | 5 +- internal/ui/proposals.go | 2 +- internal/ui/update.go | 6 +- 6 files changed, 131 insertions(+), 24 deletions(-) diff --git a/internal/execution/checkpoint.go b/internal/execution/checkpoint.go index 935ab42..0702531 100644 --- a/internal/execution/checkpoint.go +++ b/internal/execution/checkpoint.go @@ -38,6 +38,10 @@ func (cm *CheckpointManager) Create(message string) (*Checkpoint, error) { return nil, fmt.Errorf("not a git repository — cannot create checkpoint") } + if !cm.git.HasChanges() { + return nil, nil + } + hash, err := cm.git.Checkpoint(message) if err != nil { return nil, fmt.Errorf("checkpoint failed: %w", err) diff --git a/internal/git/engine.go b/internal/git/engine.go index bda23ca..2fd6240 100644 --- a/internal/git/engine.go +++ b/internal/git/engine.go @@ -136,6 +136,50 @@ func (e *Engine) Commit(subject, body string) error { return err } +// RecentMessages returns the commit message subjects (full line) for the last n commits. +func (e *Engine) RecentMessages(n int) ([]string, error) { + format := "%s" + out, err := e.git("log", fmt.Sprintf("-%d", n), fmt.Sprintf("--format=%s", format)) + if err != nil { + return nil, err + } + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) == 1 && lines[0] == "" { + return nil, nil + } + return lines, nil +} + +// DiffRange returns the diff between two refs (e.g. "HEAD~3" and "HEAD"). +func (e *Engine) DiffRange(from, to string) (string, error) { + return e.git("diff", "--no-color", from+".."+to) +} + +// ResetSoft performs a soft reset to the given ref, preserving working tree and index. +func (e *Engine) ResetSoft(ref string) error { + _, err := e.git("reset", "--soft", ref) + return err +} + +// CountConsecutiveBuildCheckpoints returns the number of consecutive commits +// at HEAD whose message starts with "izen build:". +func (e *Engine) CountConsecutiveBuildCheckpoints() int { + const prefix = "izen build:" + msgs, err := e.RecentMessages(50) + if err != nil { + return 0 + } + count := 0 + for _, m := range msgs { + if strings.HasPrefix(m, prefix) { + count++ + } else { + break + } + } + return count +} + // StageAll stages all changes (git add -A). func (e *Engine) StageAll() error { _, err := e.git("add", "-A") diff --git a/internal/ui/agents.go b/internal/ui/agents.go index 84eca6e..9f08f7b 100644 --- a/internal/ui/agents.go +++ b/internal/ui/agents.go @@ -492,6 +492,20 @@ func (m *model) runReviewCmd(target string) tea.Cmd { ) } +// initSessionStartCheckpoint creates the session-start shadow checkpoint +// to enable /undo --session even across CLI restarts. If a session-start +// checkpoint already exists, it is overwritten with the current state. +func (m *model) initSessionStartCheckpoint() tea.Msg { + if m.execEng == nil || m.execEng.ShadowCP == nil { + return nil + } + _, err := m.execEng.ShadowCP.CreateSessionStartSnapshot() + if err != nil { + return nil + } + return nil +} + func (m *model) runUndoCmd(raw string) tea.Cmd { parts := strings.Fields(raw) hasAll := false @@ -550,48 +564,88 @@ func (m *model) runUndoCmd(raw string) tea.Cmd { return nil } -func (m *model) runCommitCmdAgent() tea.Cmd { +func (m *model) runCommitCmdAgent(userMsg string) tea.Cmd { return tea.Sequence( func() tea.Msg { - return agentStartMsg{label: "generating commit message"} + label := "generating commit message" + if userMsg != "" { + label = "committing" + } + return agentStartMsg{label: label} }, m.spinnerTickCmd(), func() tea.Msg { + // ── CONSECUTIVE BUILD CHECKPOINT DETECTION ───────── + // Scan git log for consecutive "izen build:" commits at HEAD. + // These are temporary checkpoints created during /build and should + // be squashed into a single semantic commit. + buildCount := m.gitEng.CountConsecutiveBuildCheckpoints() + var squashRef string + if buildCount > 0 { + squashRef = fmt.Sprintf("HEAD~%d", buildCount) + } + + // ── STAGE ALL CHANGES ────────────────────────────── if err := m.gitEng.StageAll(); err != nil { return commitGeneratedMsg{err: fmt.Errorf("failed to stage changes: %w", err)} } - diff, err := m.gitEng.DiffCached() + // ── GET DIFF ─────────────────────────────────────── + var diff string + var err error + if squashRef != "" { + diff, err = m.gitEng.DiffRange(squashRef, "HEAD") + } else { + diff, err = m.gitEng.DiffCached() + } if err != nil { - return commitGeneratedMsg{err: fmt.Errorf("failed to get staged diff: %w", err)} + return commitGeneratedMsg{err: fmt.Errorf("failed to get diff: %w", err)} } if strings.TrimSpace(diff) == "" { return commitGeneratedMsg{err: fmt.Errorf("no changes to commit")} } - payload := commit.BuildPrompt(diff) - sys := prompt.CommitSystemPrompt() - msgs := []ai.Message{ - {Role: "system", Content: sys}, - {Role: "user", Content: payload}, - } - req := ai.Request{ - Model: m.cfg.ActiveModelName(), - Messages: msgs, - Stream: false, + // ── SQUASH BUILD CHECKPOINTS ────────────────────── + if squashRef != "" { + if err := m.gitEng.ResetSoft(squashRef); err != nil { + return commitGeneratedMsg{err: fmt.Errorf("squash failed: %w", err)} + } + if err := m.gitEng.StageAll(); err != nil { + return commitGeneratedMsg{err: fmt.Errorf("re-stage after squash failed: %w", err)} + } } - resp, err := m.provider.Execute(context.Background(), req) - if err != nil { - return commitGeneratedMsg{err: fmt.Errorf("LLM call failed: %w", err)} + + // ── GENERATE COMMIT MESSAGE ─────────────────────── + var subject, body string + if userMsg != "" { + subject = userMsg + } else { + payload := commit.BuildPrompt(diff) + sys := prompt.CommitSystemPrompt() + msgs := []ai.Message{ + {Role: "system", Content: sys}, + {Role: "user", Content: payload}, + } + req := ai.Request{ + Model: m.cfg.ActiveModelName(), + Messages: msgs, + Stream: false, + } + resp, err := m.provider.Execute(context.Background(), req) + if err != nil { + return commitGeneratedMsg{err: fmt.Errorf("LLM call failed: %w", err)} + } + parsed := commit.ParseGeneratedMessage(resp.Content) + subject = parsed.Subject + body = parsed.Body } - msg := commit.ParseGeneratedMessage(resp.Content) - if err := m.gitEng.Commit(msg.Subject, msg.Body); err != nil { + if err := m.gitEng.Commit(subject, body); err != nil { return commitGeneratedMsg{err: fmt.Errorf("commit failed: %w", err)} } hash, _ := m.gitEng.CurrentHash() - return commitGeneratedMsg{subject: msg.Subject, body: msg.Body, hash: hash} + return commitGeneratedMsg{subject: subject, body: body, hash: hash} }, ) } diff --git a/internal/ui/commands.go b/internal/ui/commands.go index c7721fd..0e0c6cb 100644 --- a/internal/ui/commands.go +++ b/internal/ui/commands.go @@ -1522,14 +1522,15 @@ func (m *model) handleCommand(cmd string) tea.Cmd { case strings.HasPrefix(cmd, "/undo"): return m.runUndoCmd(cmd) - case cmd == "/commit": + case cmd == "/commit", strings.HasPrefix(cmd, "/commit "): if m.resolver.Current() != modes.ModeBuild { m.push(roleError, "commit error: /commit is only available in /build mode") m.refreshViewportContent() m.Viewport.GotoBottom() return nil } - return m.runCommitCmdAgent() + msg := strings.TrimSpace(strings.TrimPrefix(cmd, "/commit")) + return m.runCommitCmdAgent(msg) case cmd == "/checkpoint": m.push(roleSystem, infoStyle.Render("/checkpoint not yet implemented")) diff --git a/internal/ui/proposals.go b/internal/ui/proposals.go index 3b72e87..676905a 100644 --- a/internal/ui/proposals.go +++ b/internal/ui/proposals.go @@ -462,7 +462,7 @@ func (m *model) createBuildCheckpoint(fileCount int) { cp, err := m.execEng.Checkpoints.Create(fmt.Sprintf("izen build: %d file(s)", fileCount)) if err != nil { m.push(roleSystem, infoStyle.Render("checkpoint: "+err.Error())) - } else { + } else if cp != nil { shortHash := cp.Hash if len(shortHash) > 8 { shortHash = shortHash[:8] diff --git a/internal/ui/update.go b/internal/ui/update.go index d4c4453..927a5b2 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -32,7 +32,11 @@ func (m *model) Init() tea.Cmd { if m.initStage != initNone && m.initStage != initComplete { return m.spinnerTickCmd() } - return tea.Batch(m.spinnerTickCmd(), m.ti.Focus()) + return tea.Batch( + m.spinnerTickCmd(), + m.ti.Focus(), + m.initSessionStartCheckpoint, + ) } // Update routes state machines and events. From 7b8949c67536c4b6050606ae2d20624599c07d0e Mon Sep 17 00:00:00 2001 From: andev0x Date: Fri, 24 Jul 2026 12:44:29 +0700 Subject: [PATCH 2/5] feat(ui): introduce project initialization - refactor main.go to launch TUI onboarding directly when local config is missing - add isFirstRunCheck in model.go to ensure .izen/ and config.json exist before writing to disk - update program.go to always start at the welcome screen if .izen/ does not exist --- cmd/izen/main.go | 64 +++++--------------------------------- internal/ui/agents.go | 11 +++++++ internal/ui/model.go | 19 +++++++++++ internal/ui/program.go | 10 +++--- internal/ui/update.go | 22 +++++++++++-- internal/ui/update_init.go | 51 +++++++++++++++++++++++++++--- internal/ui/view_init.go | 53 ++++++++++++++++++++++++++++++- internal/ui/workspace.go | 7 +++++ 8 files changed, 167 insertions(+), 70 deletions(-) diff --git a/cmd/izen/main.go b/cmd/izen/main.go index ea403e9..35a1347 100644 --- a/cmd/izen/main.go +++ b/cmd/izen/main.go @@ -12,15 +12,6 @@ import ( "github.com/PizenLabs/izen/internal/ui" ) -var providerEnvVars = map[string]string{ - "ollama": "", // local, always available - "anthropic": "ANTHROPIC_API_KEY", - "openai": "OPENAI_API_KEY", - "gemini": "GEMINI_API_KEY", - "openrouter": "OPENROUTER_API_KEY", - "groq": "GROQ_API_KEY", -} - var Version = "0.1.0" func printMinimalistHelp() { @@ -128,19 +119,15 @@ func main() { cfg.Username = localCfg.Username } - // ── Gate: local config missing → check env vars first ───────────────── - // When .izen/config.json does NOT exist we check if any provider API keys - // are already set in the environment. If so, we auto-create the local - // config and skip the interactive TUI init flow entirely. + // ── Gate: missing local config → launch TUI onboarding ───────────────── + // NEVER write .izen/ or .izen/config.json to disk from main.go before the + // TUI program runs. If .izen/config.json doesn't exist, launch the TUI + // directly into the interactive onboarding flow. The TUI handles env var + // detection, git init, identity setup, and provider selection — and only + // writes .izen/config.json when the user confirms the setup wizard. if _, err := os.Stat(filepath.Join(root, ".izen", "config.json")); os.IsNotExist(err) { - detectedProvider := detectProviderFromEnv(cfg) - if detectedProvider != "" { - autoCreateLocalConfig(root, cfg, detectedProvider) - fmt.Fprintf(os.Stderr, "izen: auto-configured %s from environment\n", detectedProvider) - } else { - ui.RunMainDashboard(cfg, root, localCfg) - return - } + ui.RunMainDashboard(cfg, root, localCfg) + return } // ---- Project type detection (local config exists) ---- @@ -171,41 +158,6 @@ func main() { } } -// detectProviderFromEnv checks the runtime environment for known provider -// API key env vars and returns the first detected provider name. Returns "" -// when no cloud provider keys are found (Ollama is always available). -func detectProviderFromEnv(cfg *config.Config) string { - for name, envVar := range providerEnvVars { - if name == "ollama" { - continue - } - if envVar == "" { - continue - } - if os.Getenv(envVar) != "" { - if _, ok := cfg.AI.Providers[name]; ok { - return name - } - } - } - // When no cloud key is set, check if the default is ollama (always available). - if cfg.ActiveProviderName() == "ollama" || cfg.AI.DefaultProvider == "ollama" { - return "ollama" - } - return "" -} - -// autoCreateLocalConfig writes a minimal .izen/config.json so the interactive -// TUI init flow is skipped on next launch. -func autoCreateLocalConfig(root string, cfg *config.Config, provider string) { - _ = state.InitLocalState(root) - _ = config.SaveLocalConfig(root, &config.LocalConfig{Username: cfg.Username}) - sessPath := filepath.Join(root, ".izen", "session.json") - if _, err := os.Stat(sessPath); os.IsNotExist(err) { - _ = os.WriteFile(sessPath, []byte("{}"), 0644) - } -} - func updateLocalConfig(root string, localCfg *config.LocalConfig, det project.Detection) { if localCfg == nil { localCfg = &config.LocalConfig{} diff --git a/internal/ui/agents.go b/internal/ui/agents.go index 9f08f7b..895e1de 100644 --- a/internal/ui/agents.go +++ b/internal/ui/agents.go @@ -3,6 +3,8 @@ package ui import ( "context" "fmt" + "os" + "path/filepath" "strings" "time" @@ -499,6 +501,15 @@ func (m *model) initSessionStartCheckpoint() tea.Msg { if m.execEng == nil || m.execEng.ShadowCP == nil { return nil } + // FIRST-RUN GATE: never create checkpoints (or the .izen/ directory) when + // .izen/ does not yet exist on disk. This prevents the session-start + // snapshot from spuriously creating .izen/checkpoints/ which would cause + // HasLocalState to return true and bypass the TUI onboarding flow. + if m.workspaceRoot != "" { + if _, err := os.Stat(filepath.Join(m.workspaceRoot, ".izen")); os.IsNotExist(err) { + return nil + } + } _, err := m.execEng.ShadowCP.CreateSessionStartSnapshot() if err != nil { return nil diff --git a/internal/ui/model.go b/internal/ui/model.go index 8363c4c..047cb27 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -846,6 +846,25 @@ type model struct { sessionModel string // user-selected model override via /model } +// isProjectInitialized checks whether .izen/ exists AND contains a valid +// config.json on disk. This is the AUTHORITATIVE first-run gate used by +// BuildWorkspace to decide whether to render the onboarding overlay or the +// normal mode workspace. It supersedes any in-memory initStage value. +func (m *model) isProjectInitialized() bool { + if m.workspaceRoot == "" { + return false + } + izenDir := filepath.Join(m.workspaceRoot, ".izen") + if _, err := os.Stat(izenDir); os.IsNotExist(err) { + return false + } + cfgPath := filepath.Join(m.workspaceRoot, ".izen", "config.json") + if _, err := os.Stat(cfgPath); os.IsNotExist(err) { + return false + } + return true +} + // ── Rendering helpers ───────────────────────────────────────────────────────── // wrapStreamText wraps raw text lines dynamically during an active live stream. diff --git a/internal/ui/program.go b/internal/ui/program.go index 0caccbf..56844b3 100644 --- a/internal/ui/program.go +++ b/internal/ui/program.go @@ -118,11 +118,11 @@ func NewProgram(root string, cfg *config.Config, sess *session.Session, mgr *ai. } } if !localActive { - if eng.IsRepo() { - initStage = initIdentity - } else { - initStage = initGitCheck - } + // Always start at the welcome screen (initNone) when .izen/ does not + // exist. The welcome screen handles git detection and routes to the + // correct sub-stage (initGitCheck, initIdentity) when the user presses + // Enter. This ensures the onboarding flow is never bypassed. + initStage = initNone } // ── DEFERRED GRAPH LOAD ───────────────────────────────────────────── diff --git a/internal/ui/update.go b/internal/ui/update.go index 927a5b2..7736cb0 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -134,7 +134,18 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // ── INIT STAGE ROUTING: intercept all key messages during setup ───── - if m.initStage != initNone && m.initStage != initComplete { + initActive := m.initStage != initNone && m.initStage != initComplete + if !initActive && !m.isProjectInitialized() { + // Belt-and-suspenders: project is not initialized on disk. This can + // happen when initStage is initNone (zero value) — intercept keys + // to prevent the workspace from processing them before onboarding. + if keyMsg, ok := msg.(tea.KeyMsg); ok { + m.ti.Blur() + return m.handleInitKeyMsg(keyMsg) + } + // Non-key messages always pass through to the main switch. + } + if initActive { if keyMsg, ok := msg.(tea.KeyMsg); ok { // Defensive blur: no focused textinput should consume keys // during any init stage. Each sub-handler re-focuses as needed. @@ -1987,11 +1998,16 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case gitInitResultMsg: - if msg.err != nil { + switch { + case msg.err != nil: m.initGitInitErr = msg.err.Error() - } else if m.initStage == initGitCheck { + case m.initStage == initGitCheck: m.initGitInitDone = true m.advancePastGitCheck() + case m.initStage == initNone: + // Git was initialized from the first-run welcome screen (initNone). + // Advance directly to identity setup, skipping the confirm step. + m.advancePastGitCheck() } return m, m.spinnerTickCmd() diff --git a/internal/ui/update_init.go b/internal/ui/update_init.go index fcbc3ff..17797a6 100644 --- a/internal/ui/update_init.go +++ b/internal/ui/update_init.go @@ -25,6 +25,42 @@ func (m *model) handleInitKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleInitIdentity(msg) case initProviderSelect: return m.handleInitProviderSelect(msg) + case initNone: + return m.handleInitNone(msg) + } + return m, nil +} + +func (m *model) handleInitNone(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + m.ti.Blur() + + switch { + case msg.String() == "g" || msg.String() == "G": + // Git init requested from the welcome screen + m.initGitInitDone = false + m.initGitInitErr = "" + return m, m.runGitInit() + case msg.Type == tea.KeyEnter: + // Advance to the correct first-run stage based on git status + gitPath := filepath.Join(m.workspaceRoot, ".git") + if _, err := os.Stat(gitPath); os.IsNotExist(err) { + m.initStage = initGitCheck + } else { + m.initStage = initIdentity + m.initIdentityInput = textinput.New() + m.initIdentityInput.Prompt = "" + m.initIdentityInput.CharLimit = 64 + m.initIdentityInput.Placeholder = "username" + if m.initPrefillUsername != "" { + m.userName = m.initPrefillUsername + } + m.initIdentityInput.SetValue(m.userName) + m.initIdentityInput.Focus() + } + return m, nil + case msg.Type == tea.KeyEscape: + m.initStage = initComplete + return m, m.ti.Focus() } return m, nil } @@ -107,12 +143,17 @@ func (m *model) handleInitIdentity(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.initStage = initProviderSelect m.initProviderIdx = 0 m.initProviderItems = m.buildProviderList() - // Pre-select the provider from the global profile when available. + // Providers with env vars are sorted first by buildProviderList. + // Priority: 1) global profile prefill WITH env var, 2) any env var, + // 3) first in list (ollama or default). if m.initPrefillProvider != "" { - for i, name := range m.initProviderItems { - if name == m.initPrefillProvider { - m.initProviderIdx = i - break + envVar := envVarForProvider(m.initPrefillProvider) + if envVar != "" && os.Getenv(envVar) != "" { + for i, name := range m.initProviderItems { + if name == m.initPrefillProvider { + m.initProviderIdx = i + break + } } } } diff --git a/internal/ui/view_init.go b/internal/ui/view_init.go index 82a77f6..a4eef8d 100644 --- a/internal/ui/view_init.go +++ b/internal/ui/view_init.go @@ -3,6 +3,7 @@ package ui import ( "fmt" "os" + "path/filepath" "strings" "github.com/charmbracelet/lipgloss" @@ -41,6 +42,11 @@ func (m *model) renderInitView() string { b.WriteString(m.renderInitIdentity(width)) case initProviderSelect: b.WriteString(m.renderInitProviderSelect(width)) + case initNone: + // Belt-and-suspenders: if isProjectInitialized returned false but + // initStage was left at the zero value, show a meaningful welcome + // screen with git detection instead of a blank/empty UI. + b.WriteString(m.renderInitFirstRun(width)) } b.WriteString("\n") @@ -165,12 +171,24 @@ func (m *model) renderInitProviderSelect(width int) string { b.WriteString(initDimmedStyle.Render("Choose your default AI provider. Type to filter the list.")) b.WriteString("\n\n") + // ── Env var detection banner ────────────────────────────────────── + // Show a green banner when the currently selected provider has an API + // key set in the environment. This replaces manual key entry — the + // user just presses Enter to confirm. + items := m.filteredProviders() + if len(items) > m.initProviderIdx && m.initProviderIdx >= 0 { + selected := items[m.initProviderIdx] + if envVar := envVarForProvider(selected); envVar != "" && os.Getenv(envVar) != "" { + b.WriteString(initGreenStyle.Render(" ● " + envVar + " detected from environment. Ready!")) + b.WriteString("\n\n") + } + } + if m.initProviderFilter != "" { b.WriteString(initMutedStyle.Render(" filter: ") + initTextStyle.Render(m.initProviderFilter)) b.WriteString("\n\n") } - items := m.filteredProviders() activeProvider := m.getActiveProviderName() for i, item := range items { glyph := "○" @@ -197,6 +215,37 @@ func (m *model) renderInitProviderSelect(width int) string { return b.String() } +func (m *model) renderInitFirstRun(width int) string { + var b strings.Builder + gitPath := filepath.Join(m.workspaceRoot, ".git") + _, gitMissing := os.Stat(gitPath) + gitExists := gitMissing == nil + + b.WriteString("\n") + b.WriteString(initSubStyle.Render(strings.Repeat("─", width)) + "\n") + b.WriteString("\n") + b.WriteString(initTextStyle.Render("Welcome to IZEN — engineering intelligence at your terminal.")) + b.WriteString("\n") + b.WriteString(initDimmedStyle.Render("This quick setup configures your workspace identity and AI provider.")) + b.WriteString("\n\n") + + if gitExists { + b.WriteString(initGreenStyle.Render(" ●") + initTextStyle.Render(" Git repository detected")) + } else { + b.WriteString(initRedStyle.Render(" ○") + initTextStyle.Render(" No Git repository found")) + b.WriteString("\n") + b.WriteString(initDimmedStyle.Render(" (press ") + initCyanStyle.Render("G") + initDimmedStyle.Render(" to initialize git on the 'main' branch)")) + } + b.WriteString("\n\n") + + choice := " " + initGreenStyle.Render("●") + initTextStyle.Render(" Begin setup") + " " + initDimmedStyle.Render("○ Skip") + b.WriteString(choice) + b.WriteString("\n") + b.WriteString(initDimmedStyle.Render(" (press ") + initCyanStyle.Render("Enter") + initDimmedStyle.Render(" to begin setup)")) + + return b.String() +} + func (m *model) renderInitHelp(width int) string { var hint string switch m.initStage { @@ -208,6 +257,8 @@ func (m *model) renderInitHelp(width int) string { hint = "Enter: confirm • Esc: skip" case initProviderSelect: hint = "↑/↓ to select • Enter: confirm • Type: to search" + case initNone: + hint = "Enter: begin setup • G: init git • Esc: skip" } return initMutedStyle.Render(hint) } diff --git a/internal/ui/workspace.go b/internal/ui/workspace.go index b7c2b05..4cf3598 100644 --- a/internal/ui/workspace.go +++ b/internal/ui/workspace.go @@ -212,6 +212,13 @@ func splitVis(s string, visLen int) (string, string) { } func (m *model) BuildWorkspace() Workspace { + // FIRST-RUN DISK GATE: authoritative .izen/ existence check supersedes + // any in-memory initStage value. This prevents stale/incorrect state + // (e.g., initNone zero value, initComplete from auto-create bypass) + // from rendering the workspace before the user completes onboarding. + if !m.isProjectInitialized() { + return Workspace{Overlay: m.renderInitView()} + } if m.initStage != initNone && m.initStage != initComplete { return Workspace{Overlay: m.renderInitView()} } From 1792af180eb60fbd723d97a4af54e36d7c8856ad Mon Sep 17 00:00:00 2001 From: andev0x Date: Fri, 24 Jul 2026 12:57:51 +0700 Subject: [PATCH 3/5] feat(gateway): add direct mutation verbs - include "create", "generate", "make", "write", "touch", "init" in directMutationVerbs - update test cases to reflect new verbs in classification logic --- internal/gateway/router.go | 6 ++++++ internal/gateway/router_test.go | 29 ++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/internal/gateway/router.go b/internal/gateway/router.go index 54516d1..77e61fe 100644 --- a/internal/gateway/router.go +++ b/internal/gateway/router.go @@ -42,6 +42,12 @@ var directMutationVerbs = []string{ "lowercase", "uppercase", "fix", + "create", + "generate", + "make", + "write", + "touch", + "init", } // diagnosticPatterns are regex patterns that signal a bug-hunting or diagnostic diff --git a/internal/gateway/router_test.go b/internal/gateway/router_test.go index 7002853..a961120 100644 --- a/internal/gateway/router_test.go +++ b/internal/gateway/router_test.go @@ -14,7 +14,28 @@ func TestClassifyDirectMutation(t *testing.T) { }{ // ── $prompt prefix tests ────────────────────────────────────── { - name: "$prompt rename author in @LICENSE", + name: "$prompt create MIT LICENSE", + input: "$prompt i want to create the MIT LICENSE with author named 'Maha JR' and the years 2026", + wantFastTrack: true, + wantFile: "license", + wantTaskType: "FILE_MUTATE", + }, + { + name: "$prompt generate @LICENSE", + input: "$prompt generate @LICENSE with Apache 2.0", + wantFastTrack: true, + wantFile: "LICENSE", + wantTaskType: "FILE_MUTATE", + }, + { + name: "$prompt write README.md", + input: "$prompt write README.md with project description", + wantFastTrack: true, + wantFile: "readme.md", + wantTaskType: "FILE_MUTATE", + }, + { + name: "rename author in @LICENSE", input: "$prompt rename author in @LICENSE file into 'Jay JR'", wantFastTrack: true, wantFile: "LICENSE", @@ -286,6 +307,12 @@ func TestClassifyDirectMutation_VerbDetection(t *testing.T) { "format", "correct", "capitalize", + "create", + "generate", + "make", + "write", + "touch", + "init", } for _, v := range verbs { From b5ece06f94a7b699ed710dfcd783bc64c19d9dac Mon Sep 17 00:00:00 2001 From: andev0x Date: Fri, 24 Jul 2026 13:56:41 +0700 Subject: [PATCH 4/5] fix(architecture): add conventional commit - define commit philosophy and entry point - describe build checkpoint squashing logic - outline commit message pipeline including LLM generation and sanitization --- docs/architecture/wiki/COMMIT-WIKI.md | 361 ++++++++++++++++++++++++++ internal/execution/patch.go | 3 +- internal/git/engine.go | 14 + internal/ui/agents.go | 41 ++- internal/ui/commands.go | 6 +- 5 files changed, 415 insertions(+), 10 deletions(-) create mode 100644 docs/architecture/wiki/COMMIT-WIKI.md diff --git a/docs/architecture/wiki/COMMIT-WIKI.md b/docs/architecture/wiki/COMMIT-WIKI.md new file mode 100644 index 0000000..b26aa7b --- /dev/null +++ b/docs/architecture/wiki/COMMIT-WIKI.md @@ -0,0 +1,361 @@ +# Conventional Commit Squash Engine + +> Transform a chain of ephemeral `izen build:` checkpoints into a single +> semantic conventional commit with deterministic sanitization and zero +> edge-case crashes. + +## Philosophy + +The commit system is built on a simple discipline: + +**Checkpoints are for machines. Commits are for humans.** + +Every `/build` session creates a sequence of temporary `izen build:` checkpoints — +fast, frequent, disposable. The `/commit` command exists to collapse that noisy +timeline into a single, reviewable conventional commit with a coherent message. + +This aligns with Izen's core philosophy: **AI produces intermediate artifacts; +humans own the polished result.** The commit engine preserves the full diff, +generates a context-aware message from the LLM, and aggressively sanitizes +implementation leaks so the final commit reads like a human wrote it. + +--- + +## Entry Point: `/commit` + +The `/commit` command is **only available in `/build` mode**. It is gated by the +mode resolver at `internal/ui/commands.go`: + +``` +User types "/commit" or "/commit " + │ + ▼ (must be in ModeBuild) +commands.go → runCommitCmdAgent(userMsg) + │ + ▼ +agents.go → tea.Sequence { spinner, core logic } + │ + ▼ +commitGeneratedMsg{subject, body, hash, err} + │ + ▼ +update.go → display "[✓] Commit: · " +``` + +If the user provides an inline message (`/commit add license file`), it becomes +the commit subject directly — LLM generation is **bypassed entirely**. + +--- + +## Build Checkpoint Squashing + +The core squash logic lives in `internal/ui/agents.go:runCommitCmdAgent()`. + +### Detection + +``` +HEAD~N ←── izen build: 3 file(s) ← newest (HEAD) + izen build: 1 file(s) + izen build: 2 file(s) +HEAD ←── feat(license): add MIT ← oldest consecutive build + ... (prior commits) +``` + +`CountConsecutiveBuildCheckpoints()` scans the last 50 commit subjects for the +`izen build:` prefix and counts consecutive matches from HEAD backward. + +### Squash Algorithm + +| Step | Action | Git Command | +|------|--------|-------------| +| 1 | `StageAll()` | `git add -A` | +| 2 | `DiffRange(squashRef, "HEAD")` | `git diff HEAD~N..HEAD --no-color` | +| 3 | `ResetSoft(squashRef)` | `git reset --soft HEAD~N` | +| 4 | `StageAll()` (re-stage) | `git add -A` | +| 5 | LLM generation → `Commit(subject, body)` | `git commit -m -m ` | + +This squashes N build checkpoints + any new working changes into a single commit. + +### Boundary Protection + +`HEAD~N` must NEVER exceed the repository's total commit count. The engine +retrieves `TotalCommits()` via `git rev-list --count HEAD` and clamps N: + +| Scenario | N clamped to | Diff base | Commit strategy | +|----------|-------------|-----------|-----------------| +| Normal (`N < total`) | `N` | `HEAD~N..HEAD` | `Commit(subject, body)` | +| All builds (`N >= total, total > 1`) | `total - 1` | empty tree → `HEAD` | `ResetSoft` + `Commit` | +| Sole build (`total == 1`) | clamped to 0 | empty tree → `HEAD` | `AmendCommit(message)` | + +The **empty tree hash** `4b825dc642cb6eb9a060e54bf8d69288fbee4904` is a +well-known SHA that resolves in every Git repository — it represents "no files." +When all commits are build checkpoints, there is no parent to diff against, so +the engine diffs against the empty tree instead. + +When the **sole commit** in the repository is a build checkpoint (`total == 1 && +buildCount == 1`), `HEAD~0 = HEAD` is a no-op for soft reset. The engine uses +`git commit --amend` to rewrite the single commit's message instead of adding a +new commit on top. + +--- + +## Commit Message Pipeline + +When `userMsg` is empty, the message is generated through a two-stage pipeline: + +### Stage 1: LLM Generation + +``` +System Prompt (commit.go) User Prompt (executor.go) + ┌──────────────────────┐ ┌──────────────────────────┐ + │ Staff Software Eng. │ │ Generate a conventional │ + │ Writing Git commits │ │ commit message for these │ + │ │ │ changes: │ + │ Output format: │ │ │ + │ (): ... │ │ diff --git a/LICENSE ... │ + │ │ │ +MIT License │ + │ Rules + forbidden │ │ +Copyright (c) 2024 ... │ + │ Good example │ └──────────────────────────┘ + └──────────────────────┘ + │ │ + └─────── LLM ──────────┘ + │ + ▼ + Raw LLM Response + feat(license): add MIT license file + + - include full MIT license text + - set copyright holder placeholder +``` + +### Stage 2: Sanitization Pipeline + +`ParseGeneratedMessage()` (`internal/modes/commit/executor.go`) processes the +raw LLM output: + +``` +Raw LLM Response + │ + ▼ +CleanRawLLMOutput() — strip ``` markdown fences + │ + ▼ +Split into lines + │ + ├─ First non-empty line → SanitizeSubject() + │ │ + │ ├─ Strip enclosing quotes and trailing periods + │ ├─ Validate conventional-commit format (type(scope): summary) + │ │ └─ Fallback: "chore(repo): update repository state" + │ ├─ Lowercase first character of summary + │ ├─ Strip forbidden hanging particles (with, for, to, into, using, + │ │ include, includes, including, and, or, via, through, by) + │ ├─ Strip trailing incomplete-word fragments (loop) + │ └─ Smart truncation at word boundary if > 48 chars + │ + └─ Remaining lines → SanitizeBody() + │ + ├─ Strip list symbols (-, *, •) + ├─ Strip trailing periods + ├─ Filter implementation leaks (regex): + │ - `` `backtick code` `` + │ - pass N, function, resolver, edge, constant + │ - enum, phase, internal, struct + ├─ Filter duplicate conventional scope lines + ├─ Lowercase first character + └─ Format as "- bullet" (max 4 bullets) + │ + ▼ +CommitMessage{Subject, Body} +``` + +### SubjectRE + +`internal/modes/commit/executor.go` validates the conventional commit format: + +```go +var SubjectRE = regexp.MustCompile(`^[a-z]+\([a-z][a-z0-9._/-]*\): .+`) +``` + +### ForbiddenEndings + +Weak trailing particles that are progressively stripped from the subject: + +``` +with, for, to, into, using, include, includes, including, and, or, +via, through, by +``` + +### Forbidden Vague Verbs (LLM prompt level) + +The system prompt explicitly forbids: `enhance`, `improve`, `optimize`, `refine`, `strengthen` + +--- + +## Diff Sources + +The engine determines which diff to feed to the LLM based on the squash context: + +``` + ┌───────────────────────────────┐ + │ Commit Flow Decision │ + │ │ + │ useEmptyTree? │ + │ ┌─── yes ───→ empty tree..HEAD │ + │ │ │ + │ └─── no ───→ squashRef set? │ + │ ├── yes → squashRef..HEAD + │ └── no → DiffCached() + └───────────────────────────────┘ +``` + +| Source | When | Git Command | +|--------|------|-------------| +| Empty tree | All commits are build checkpoints | `git diff 4b825dc6..HEAD --no-color` | +| Squash range | Build checkpoints exist | `git diff HEAD~N..HEAD --no-color` | +| Cached | No build checkpoints | `git diff --cached --no-color` | + +All diffs are limited to **180 lines** via `head -n 180` in the staged/working +diff extraction functions. + +--- + +## Fallback Chain + +When any stage of the pipeline fails, the engine degrades gracefully: + +| Failure | Fallback | +|---------|----------| +| LLM returns empty/parse error | `ParseGeneratedMessage` defaults to `chore(repo): update repository state` | +| Subject missing colon | `SanitizeSubject` → `chore(repo): update repository state` | +| Subject empty after sanitization | `chore(repo): update repository state` | +| Body empty after sanitization | `- apply repository changes` | +| All sanitized bullets filtered | `- apply repository changes` | +| Diff empty (no changes) | Error: `no changes to commit` — commit aborted | +| Staged diff empty | Falls back to `GetWorkingDiff()` for unstaged changes | + +The `BuildFallback()` function provides a static fallback when LLM inference is +completely unavailable: + +```go +func BuildFallback(status string) CommitMessage { + count := len(strings.Split(status, "\n")) + return CommitMessage{ + Subject: fmt.Sprintf("chore(repo): update %d files", count), + Body: "- apply repository changes", + } +} +``` + +--- + +## LLM System Prompt + +File: `internal/prompt/commit.go` — instructs a "Staff Software Engineer" to +produce commit messages in this exact format: + +``` +(): + +- +- +``` + +### Type Priority + +``` +feat > fix > docs > style > refactor > test > build > ci > chore +``` + +### Good Example + +``` +feat(license): add MIT license file + +- include full MIT license text +- set copyright holder placeholder +``` + +--- + +## Relationship to Other Systems + +### Build Mode (`/build`) + +The build engine creates `izen build:` checkpoints via `Checkpoint()` during +file mutation. The commit engine squashes these. The build engine's +`RecordPatch()` function in `internal/modes/build/summary.go` marks plan tasks +as completed in the shared ledger after `/commit` succeeds. + +### Undo System (`/undo`) + +The undo system operates independently — it uses shadow checkpoints (Git tree +objects, never visible commits) stored by the checkpoint engine +(`internal/checkpoint/engine.go`). The `/commit` flow does NOT interact with +the checkpoint engine. + +### Plan Mode (`/plan`) + +Plan tasks are consumed by `/build`. After `/commit`, tasks are marked as +Completed in the plan task ledger (`InternalLedger`) via `RecordPatch()`. + +--- + +## Key Design Decisions + +1. **No dedicated commit mode** — `/commit` is a sub-command of `/build` mode +2. **Two `-m` flags** — avoids shell escaping issues with multi-line messages +3. **User message bypasses LLM** — `/commit add license` uses the message directly +4. **Aggressive sanitization** — implementation leaks (backtick code, function/struct + names) are filtered from body bullets +5. **180-line diff limit** — prevents context window overflow from massive diffs +6. **Empty tree hash** — protects against fresh repos where all commits are checkpoints +7. **Shadow checkpoints** — the undo system uses Git tree objects (not commits), + keeping history clean + +--- + +## File Layout + +``` +internal/ +├── git/ +│ ├── engine.go # StageAll, Commit, AmendCommit, ResetSoft, +│ │ DiffRange, DiffCached, Checkpoint, +│ │ CountConsecutiveBuildCheckpoints, TotalCommits +│ └── git_test.go # 14 tests for git operations +│ +├── modes/commit/ +│ ├── engine.go # SanitizeSubject, SanitizeBody, GetStagedDiff, +│ │ GetWorkingDiff, IsImplementationLeak, +│ │ BuildFallback, CleanRawLLMOutput, ExecuteCommit +│ ├── executor.go # SubjectRE, BuildPrompt, ParseGeneratedMessage +│ └── engine_test.go # 17 tests covering all sanitization paths +│ +├── prompt/ +│ └── commit.go # CommitSystemPrompt with format rules + examples +│ +└── ui/ + ├── agents.go # runCommitCmdAgent — the main orchestrator + ├── commands.go # /commit route, mode guard + ├── model.go # commitGeneratedMsg struct + └── update.go # commitGeneratedMsg handler + TUI display +``` + +--- + +## Contract Guarantees + +| Guarantee | Enforcement | +|-----------|-------------| +| **No `HEAD~N` boundary crash** | `TotalCommits()` clamp + empty tree fallback + amend for sole commit | +| **No `izen build: 0 file(s)` commits** | `CheckpointManager.Create` checks `HasChanges()` first; handoff checkpoint removed | +| **Squash always reversible** | Soft reset preserves working tree and index; nothing is destroyed | +| **LLM bypass on user message** | `/commit ` sets `subject = userMsg` directly, skips LLM call | +| **Implementation leaks filtered** | `IsImplementationLeak()` regex blocks backtick code, struct/enum/function names | +| **Subject always ≤ 48 chars** | `SanitizeSubject` smart-truncates at word boundary | +| **Body always ≤ 4 bullets** | `SanitizeBody` caps at `MaxBodyBullets` | +| **No markdown fences in message** | `CleanRawLLMOutput` strips ``` fences before parsing | +| **Forbidden endings stripped** | `ForbiddenEndings` map + loop purge in `SanitizeSubject` | +| **Deterministic fallback** | `ParseGeneratedMessage` never returns empty; falls back to `chore(repo): ...` | +| **Conventional commit enforced** | `SubjectRE` validates; missing colon → automatic fallback | diff --git a/internal/execution/patch.go b/internal/execution/patch.go index 305b72d..afa060a 100644 --- a/internal/execution/patch.go +++ b/internal/execution/patch.go @@ -1233,7 +1233,8 @@ func parseFileCreateBlocks(content string) []fileCreateBlock { filePath = strings.TrimSpace(pathPart) continue } - if inBlock && (trimmed == ">>>>>>> END_FILE" || strings.HasPrefix(trimmed, ">>>>>>> END_FILE")) { + if inBlock && (trimmed == ">>>>>>> END_FILE" || strings.HasPrefix(trimmed, ">>>>>>> END_FILE") || + trimmed == "======= END_FILE" || strings.HasPrefix(trimmed, "======= END_FILE")) { blocks = append(blocks, fileCreateBlock{ FilePath: filePath, Content: strings.Join(contentLines, "\n"), diff --git a/internal/git/engine.go b/internal/git/engine.go index 2fd6240..3fd8187 100644 --- a/internal/git/engine.go +++ b/internal/git/engine.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "time" ) @@ -180,6 +181,19 @@ func (e *Engine) CountConsecutiveBuildCheckpoints() int { return count } +// TotalCommits returns the total number of commits reachable from HEAD. +func (e *Engine) TotalCommits() (int, error) { + out, err := e.git("rev-list", "--count", "HEAD") + if err != nil { + return 0, err + } + out = strings.TrimSpace(out) + if out == "" { + return 0, nil + } + return strconv.Atoi(out) +} + // StageAll stages all changes (git add -A). func (e *Engine) StageAll() error { _, err := e.git("add", "-A") diff --git a/internal/ui/agents.go b/internal/ui/agents.go index 895e1de..5c2c743 100644 --- a/internal/ui/agents.go +++ b/internal/ui/agents.go @@ -590,10 +590,26 @@ func (m *model) runCommitCmdAgent(userMsg string) tea.Cmd { // Scan git log for consecutive "izen build:" commits at HEAD. // These are temporary checkpoints created during /build and should // be squashed into a single semantic commit. + // CRITICAL: Clamp HEAD~N so it never exceeds the repo's total + // commit count. When all commits are build checkpoints, diff + // against the empty tree (no parent commit exists). + const emptyTreeHash = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + buildCount := m.gitEng.CountConsecutiveBuildCheckpoints() var squashRef string + useEmptyTree := false + totalCommits := 0 + if buildCount > 0 { - squashRef = fmt.Sprintf("HEAD~%d", buildCount) + totalCommits, _ = m.gitEng.TotalCommits() + if totalCommits > 0 && buildCount >= totalCommits { + useEmptyTree = true + if totalCommits > 1 { + squashRef = fmt.Sprintf("HEAD~%d", totalCommits-1) + } + } else { + squashRef = fmt.Sprintf("HEAD~%d", buildCount) + } } // ── STAGE ALL CHANGES ────────────────────────────── @@ -604,9 +620,12 @@ func (m *model) runCommitCmdAgent(userMsg string) tea.Cmd { // ── GET DIFF ─────────────────────────────────────── var diff string var err error - if squashRef != "" { + switch { + case useEmptyTree: + diff, err = m.gitEng.DiffRange(emptyTreeHash, "HEAD") + case squashRef != "": diff, err = m.gitEng.DiffRange(squashRef, "HEAD") - } else { + default: diff, err = m.gitEng.DiffCached() } if err != nil { @@ -651,8 +670,20 @@ func (m *model) runCommitCmdAgent(userMsg string) tea.Cmd { body = parsed.Body } - if err := m.gitEng.Commit(subject, body); err != nil { - return commitGeneratedMsg{err: fmt.Errorf("commit failed: %w", err)} + // When the sole commit is a build checkpoint (no parent to + // squash against), amend it instead of creating a new commit. + if useEmptyTree && totalCommits == 1 { + msg := subject + if body != "" { + msg = subject + "\n\n" + body + } + if err := m.gitEng.AmendCommit(msg); err != nil { + return commitGeneratedMsg{err: fmt.Errorf("amend failed: %w", err)} + } + } else { + if err := m.gitEng.Commit(subject, body); err != nil { + return commitGeneratedMsg{err: fmt.Errorf("commit failed: %w", err)} + } } hash, _ := m.gitEng.CurrentHash() diff --git a/internal/ui/commands.go b/internal/ui/commands.go index 0e0c6cb..d4dfbe4 100644 --- a/internal/ui/commands.go +++ b/internal/ui/commands.go @@ -4117,11 +4117,9 @@ func (m *model) injectHandoffContext(mode modes.Mode) { // /build consumes ONLY the atomic structural tasks (PendingTodos / // staged tasks) produced by /plan. The raw ProposedFix chat blob from // an earlier phase is purged here so it can never re-inject stale - // conversational text into the build workspace. We keep a checkpoint - // for reversibility regardless. + // conversational text into the build workspace. if len(m.handoffCtx.PendingTodos) > 0 || len(m.sess.CurrentTasks) > 0 { - m.createBuildCheckpoint(0) - m.push(roleSystem, "Handoff context injected. Checkpoint created.") + m.push(roleSystem, "Handoff context injected.") } // Purge stale conversational handoff so the build buffer stays clean. m.handoffCtx.ProposedFix = "" From 570033b164a142f21a5f5111b71bc78d77bc2b1e Mon Sep 17 00:00:00 2001 From: andev0x Date: Fri, 24 Jul 2026 15:09:42 +0700 Subject: [PATCH 5/5] feat(impact): add max_tokens and stop parameters - resolve parsing and applying search/replace blocks - update tests for new functionality - refactor SearchReplaceBlocks parsing and application logic --- internal/ai/provider.go | 2 + internal/execution/patch.go | 62 +++++++++++++++++++++++---- internal/execution/patch_test.go | 20 ++++----- internal/gateway/router.go | 50 ++++++++++++++++++++++ internal/modes/plan/engine.go | 3 +- internal/modes/plan/truncate.go | 3 +- internal/providers/claude.go | 41 +++++++++++------- internal/providers/gemini.go | 12 +++++- internal/providers/groq.go | 24 +++++++---- internal/providers/ollama.go | 17 +++++--- internal/providers/openai.go | 12 ++++-- internal/providers/openrouter.go | 12 ++++-- internal/ui/commands.go | 30 ++++++++----- internal/ui/proposals.go | 72 +++++++++++++++++++++++++++++++- 14 files changed, 289 insertions(+), 71 deletions(-) diff --git a/internal/ai/provider.go b/internal/ai/provider.go index 0d1dba8..8cfc536 100644 --- a/internal/ai/provider.go +++ b/internal/ai/provider.go @@ -20,6 +20,8 @@ type Request struct { Messages []Message `json:"messages"` Stream bool `json:"stream"` System string `json:"-"` // Explicit system prompt (top-level for Anthropic, prepended for OpenAI-compatible) + MaxTokens int `json:"-"` // 0 = use provider default + Stop []string `json:"-"` // Optional stop sequences (e.g. [">>>>>>>"]) ResponseFormat *ResponseFormat `json:"response_format,omitempty"` } diff --git a/internal/execution/patch.go b/internal/execution/patch.go index afa060a..1ece294 100644 --- a/internal/execution/patch.go +++ b/internal/execution/patch.go @@ -626,8 +626,8 @@ func (pm *PatchManager) Apply(patch *Patch) error { globalActivityLog("[patch] Unified diff mismatch on %s — retrying as SEARCH/REPLACE block", patch.File) } // Try SEARCH/REPLACE blocks first (METHOD C) - if blocks := parseSearchReplaceBlocks(diffInput); len(blocks) > 0 { - if replaced, ok := applySearchReplaceBlockFromBlocks(patch.Original, blocks); ok && replaced != patch.Original { + if blocks := ParseSearchReplaceBlocks(diffInput); len(blocks) > 0 { + if replaced, ok := ApplySearchReplaceBlocks(patch.Original, blocks); ok && replaced != patch.Original { final = replaced if globalActivityLog != nil { globalActivityLog("[patch] SEARCH/REPLACE block fallback succeeded for %s", patch.File) @@ -651,8 +651,8 @@ func (pm *PatchManager) Apply(patch *Patch) error { case patch.Original != "": // Try SEARCH/REPLACE block format (METHOD C) — unambiguous markers // that provide exact search context and replacement content. - if blocks := parseSearchReplaceBlocks(diffInput); len(blocks) > 0 { - if replaced, ok := applySearchReplaceBlockFromBlocks(patch.Original, blocks); ok && replaced != patch.Original { + if blocks := ParseSearchReplaceBlocks(diffInput); len(blocks) > 0 { + if replaced, ok := ApplySearchReplaceBlocks(patch.Original, blocks); ok && replaced != patch.Original { final = replaced if globalActivityLog != nil { globalActivityLog("[patch] SEARCH/REPLACE block applied to %s", patch.File) @@ -1252,17 +1252,63 @@ func parseFileCreateBlocks(content string) []fileCreateBlock { return blocks } +// ResolveModifiedContent takes the original file content and the raw LLM output +// (which may be a SEARCH/REPLACE block, unified diff, or full file content) and +// returns the actual modified content. This is used for computing accurate +// display diffs — the PatchManager.Apply path has its own resolution logic. +func ResolveModifiedContent(original, rawLLMOutput string) string { + input := strings.TrimSpace(rawLLMOutput) + if input == "" { + return input + } + + // Strip outer markdown code fences if present. + if strings.HasPrefix(input, "```") { + if idx := strings.Index(input, "\n"); idx != -1 { + input = input[idx+1:] + } + } + input = strings.TrimSuffix(input, "```") + input = strings.TrimSpace(input) + + if original == "" { + return input + } + + // Strategy 1: SEARCH/REPLACE blocks + if strings.Contains(input, "<<<<<<< SEARCH") { + if blocks := ParseSearchReplaceBlocks(input); len(blocks) > 0 { + if modified, ok := ApplySearchReplaceBlocks(original, blocks); ok && modified != original { + return modified + } + } + // If SEARCH/REPLACE blocks are present but don't match, return original + // unchanged rather than passing through raw markers. + return original + } + + // Strategy 2: Unified diff with @@ hunk headers + if strings.Contains(input, "@@") { + if modified, err := applyUnifiedPatch(original, input); err == nil && modified != original { + return modified + } + } + + // Strategy 3: Treated as full file content + return input +} + // searchReplaceBlock represents a parsed <<<<<<< SEARCH ... ======= ... >>>>>>> block. type searchReplaceBlock struct { search string replace string } -// parseSearchReplaceBlocks scans content for <<<<<<< SEARCH ... ======= ... >>>>>>> +// ParseSearchReplaceBlocks scans content for <<<<<<< SEARCH ... ======= ... >>>>>>> // blocks and returns the parsed blocks. Each block contains the search text // (between SEARCH and =======) and the replace text (between ======= and >>>>>>>). // Returns nil if no valid blocks are found. -func parseSearchReplaceBlocks(content string) []searchReplaceBlock { +func ParseSearchReplaceBlocks(content string) []searchReplaceBlock { var blocks []searchReplaceBlock lines := strings.Split(content, "\n") @@ -1310,7 +1356,7 @@ func parseSearchReplaceBlocks(content string) []searchReplaceBlock { return blocks } -// applySearchReplaceBlockFromBlocks applies parsed SEARCH/REPLACE blocks to the +// ApplySearchReplaceBlocks applies parsed SEARCH/REPLACE blocks to the // original content. For each block, it finds the SEARCH text in the original and // replaces it with the REPLACE text. Returns (result, true) on success or // (original, false) if any block's SEARCH text cannot be found. @@ -1322,7 +1368,7 @@ func parseSearchReplaceBlocks(content string) []searchReplaceBlock { // from each line and compares trimmed content. This handles the "patch hunk // does not match file content" error caused by whitespace/indentation drift // between the model's SEARCH block and the actual file content. -func applySearchReplaceBlockFromBlocks(original string, blocks []searchReplaceBlock) (string, bool) { +func ApplySearchReplaceBlocks(original string, blocks []searchReplaceBlock) (string, bool) { if original == "" || len(blocks) == 0 { return original, false } diff --git a/internal/execution/patch_test.go b/internal/execution/patch_test.go index bd20433..13dd022 100644 --- a/internal/execution/patch_test.go +++ b/internal/execution/patch_test.go @@ -469,7 +469,7 @@ func TestFirstNonEmptyLine(t *testing.T) { func TestParseSearchReplaceBlocks(t *testing.T) { t.Run("single block", func(t *testing.T) { input := "<<<<<<< SEARCH\nline1\nline2\n=======\nline1\nline2_modified\n>>>>>>>" - blocks := parseSearchReplaceBlocks(input) + blocks := ParseSearchReplaceBlocks(input) if len(blocks) != 1 { t.Fatalf("expected 1 block, got %d", len(blocks)) } @@ -483,7 +483,7 @@ func TestParseSearchReplaceBlocks(t *testing.T) { t.Run("multiple blocks", func(t *testing.T) { input := "<<<<<<< SEARCH\nold1\n=======\nnew1\n>>>>>>>\nsome stuff\n<<<<<<< SEARCH\nold2\n=======\nnew2\n>>>>>>>" - blocks := parseSearchReplaceBlocks(input) + blocks := ParseSearchReplaceBlocks(input) if len(blocks) != 2 { t.Fatalf("expected 2 blocks, got %d", len(blocks)) } @@ -496,7 +496,7 @@ func TestParseSearchReplaceBlocks(t *testing.T) { }) t.Run("no blocks returns nil", func(t *testing.T) { - blocks := parseSearchReplaceBlocks("just some random content\nno markers here") + blocks := ParseSearchReplaceBlocks("just some random content\nno markers here") if len(blocks) != 0 { t.Fatalf("expected 0 blocks, got %d", len(blocks)) } @@ -504,7 +504,7 @@ func TestParseSearchReplaceBlocks(t *testing.T) { t.Run("malformed block missing replace", func(t *testing.T) { input := "<<<<<<< SEARCH\nold\n=======\n>>>>>>>" - blocks := parseSearchReplaceBlocks(input) + blocks := ParseSearchReplaceBlocks(input) if len(blocks) != 1 { t.Fatalf("expected 1 block, got %d", len(blocks)) } @@ -514,7 +514,7 @@ func TestParseSearchReplaceBlocks(t *testing.T) { }) t.Run("empty content", func(t *testing.T) { - blocks := parseSearchReplaceBlocks("") + blocks := ParseSearchReplaceBlocks("") if len(blocks) != 0 { t.Fatalf("expected 0 blocks, got %d", len(blocks)) } @@ -528,7 +528,7 @@ func TestApplySearchReplaceBlockFromBlocks(t *testing.T) { search: "\tprintln(\"hello\")", replace: "\tprintln(\"world\")", }} - result, ok := applySearchReplaceBlockFromBlocks(original, blocks) + result, ok := ApplySearchReplaceBlocks(original, blocks) if !ok { t.Fatal("expected successful replacement") } @@ -546,7 +546,7 @@ func TestApplySearchReplaceBlockFromBlocks(t *testing.T) { {search: "line1\n", replace: "changed1\n"}, {search: "line3\n", replace: "changed3\n"}, } - result, ok := applySearchReplaceBlockFromBlocks(original, blocks) + result, ok := ApplySearchReplaceBlocks(original, blocks) if !ok { t.Fatal("expected successful replacement") } @@ -564,21 +564,21 @@ func TestApplySearchReplaceBlockFromBlocks(t *testing.T) { t.Run("search not found returns false", func(t *testing.T) { original := "package main\n" blocks := []searchReplaceBlock{{search: "nonexistent", replace: "replacement"}} - _, ok := applySearchReplaceBlockFromBlocks(original, blocks) + _, ok := ApplySearchReplaceBlocks(original, blocks) if ok { t.Fatal("expected false when search not found") } }) t.Run("empty original returns false", func(t *testing.T) { - _, ok := applySearchReplaceBlockFromBlocks("", []searchReplaceBlock{{search: "x", replace: "y"}}) + _, ok := ApplySearchReplaceBlocks("", []searchReplaceBlock{{search: "x", replace: "y"}}) if ok { t.Fatal("expected false for empty original") } }) t.Run("empty blocks returns false", func(t *testing.T) { - _, ok := applySearchReplaceBlockFromBlocks("original", nil) + _, ok := ApplySearchReplaceBlocks("original", nil) if ok { t.Fatal("expected false for nil blocks") } diff --git a/internal/gateway/router.go b/internal/gateway/router.go index 77e61fe..22a20be 100644 --- a/internal/gateway/router.go +++ b/internal/gateway/router.go @@ -96,6 +96,56 @@ var directMutationBareFiles = []string{ "changelog", "changelog.md", } +// knownConventions maps lowercased filenames to their canonical (correct-case) +// form. All lookups should be done with the lowercased key; the value +// preserves the conventional casing (e.g. "LICENSE", "README.md"). +var knownConventions = map[string]string{ + "license": "LICENSE", + "licence": "LICENSE", + "readme": "README.md", + "readme.md": "README.md", + "dockerfile": "Dockerfile", + "makefile": "Makefile", + "env": ".env", + ".env": ".env", + "env.example": ".env.example", + ".env.example": ".env.example", + "gitignore": ".gitignore", + ".gitignore": ".gitignore", + "dockerignore": ".dockerignore", + ".dockerignore": ".dockerignore", + "contributing": "CONTRIBUTING.md", + "contributing.md": "CONTRIBUTING.md", + "changelog": "CHANGELOG.md", + "changelog.md": "CHANGELOG.md", +} + +// CanonicalizeFileName maps a file path returned by the LLM to its +// conventional-cased form. It checks each path segment against known +// conventions (e.g. "license" → "LICENSE", "readme" → "README.md"). +// Non-convention paths are returned unchanged. +func CanonicalizeFileName(path string) string { + if path == "" { + return path + } + // Try the full path first (handles ".env", ".gitignore", etc.) + lower := strings.ToLower(path) + if canon, ok := knownConventions[lower]; ok { + return canon + } + // Check bare filename (last segment) + base := filepath.Base(path) + baseLower := strings.ToLower(base) + if canon, ok := knownConventions[baseLower]; ok { + dir := filepath.Dir(path) + if dir == "." || dir == "" { + return canon + } + return filepath.Join(dir, canon) + } + return path +} + // ClassifyDirectMutation inspects user input to determine whether it is a // simple single-file text mutation that should bypass the Senior Architect // pipeline and route directly to the /build engine as a FILE_MUTATE task. diff --git a/internal/modes/plan/engine.go b/internal/modes/plan/engine.go index 7d724f5..c59b3aa 100644 --- a/internal/modes/plan/engine.go +++ b/internal/modes/plan/engine.go @@ -268,7 +268,8 @@ func (e *Engine) processFromLedger(ctx context.Context, ledgerContent string, pr Content: fastPrompt[0], }, }, - Stream: false, + Stream: false, + MaxTokens: 500, } } else { // Extract the investigation conclusion so it can be injected as a diff --git a/internal/modes/plan/truncate.go b/internal/modes/plan/truncate.go index 0672301..a58c276 100644 --- a/internal/modes/plan/truncate.go +++ b/internal/modes/plan/truncate.go @@ -448,6 +448,7 @@ func FastTrackPrompt(coreError, conclusion string) string { "RULES: use ONLY the SHELL_EXEC task type. NEVER emit FILE_MUTATE tasks. " + "NEVER use placeholder paths like 'relative/path/to/file.go' or " + "'file_test.go'. The SHELL_EXEC target must be a real, runnable command " + - "(e.g. 'go get github.com/foo/bar' or 'go mod tidy')." + "(e.g. 'go get github.com/foo/bar' or 'go mod tidy').\n" + + "STRICT: Output ONLY the checklist. NO explanations. NO markdown prose. NO code fences." return prompt } diff --git a/internal/providers/claude.go b/internal/providers/claude.go index ccbb32a..a16f853 100644 --- a/internal/providers/claude.go +++ b/internal/providers/claude.go @@ -37,11 +37,12 @@ type claudeMessage struct { } type claudeRequest struct { - Model string `json:"model"` - Messages []claudeMessage `json:"messages"` - MaxTokens int `json:"max_tokens"` - Stream bool `json:"stream"` - System string `json:"system,omitempty"` + Model string `json:"model"` + Messages []claudeMessage `json:"messages"` + MaxTokens int `json:"max_tokens"` + Stream bool `json:"stream"` + System string `json:"system,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` } type claudeResponse struct { @@ -92,12 +93,17 @@ func (p *ClaudeProvider) Execute(ctx context.Context, req ai.Request) (*ai.Respo msgs := p.buildMessages(req) + maxTokens := req.MaxTokens + if maxTokens <= 0 { + maxTokens = 4096 + } body := claudeRequest{ - Model: model, - Messages: msgs, - MaxTokens: 4096, - Stream: false, - System: req.System, + Model: model, + Messages: msgs, + MaxTokens: maxTokens, + Stream: false, + System: req.System, + StopSequences: req.Stop, } payload, err := json.Marshal(body) @@ -158,12 +164,17 @@ func (p *ClaudeProvider) ExecuteStream(ctx context.Context, req ai.Request) (io. msgs := p.buildMessages(req) + maxTokens := req.MaxTokens + if maxTokens <= 0 { + maxTokens = 4096 + } body := claudeRequest{ - Model: model, - Messages: msgs, - MaxTokens: 4096, - Stream: true, - System: req.System, + Model: model, + Messages: msgs, + MaxTokens: maxTokens, + Stream: true, + System: req.System, + StopSequences: req.Stop, } payload, err := json.Marshal(body) diff --git a/internal/providers/gemini.go b/internal/providers/gemini.go index 76bb569..351dcd5 100644 --- a/internal/providers/gemini.go +++ b/internal/providers/gemini.go @@ -114,11 +114,15 @@ func (p *GeminiProvider) Execute(ctx context.Context, req ai.Request) (*ai.Respo msgs := p.buildMessages(req) + maxTokens := req.MaxTokens + if maxTokens <= 0 { + maxTokens = 4096 + } body := geminiRequest{ Contents: msgs, Stream: false, GenerationConfig: &geminiGenerationConfig{ - MaxOutputTokens: 4096, + MaxOutputTokens: maxTokens, }, } @@ -184,11 +188,15 @@ func (p *GeminiProvider) ExecuteStream(ctx context.Context, req ai.Request) (io. msgs := p.buildMessages(req) + maxTokens := req.MaxTokens + if maxTokens <= 0 { + maxTokens = 4096 + } body := geminiRequest{ Contents: msgs, Stream: true, GenerationConfig: &geminiGenerationConfig{ - MaxOutputTokens: 4096, + MaxOutputTokens: maxTokens, }, } diff --git a/internal/providers/groq.go b/internal/providers/groq.go index 09c4144..0008faa 100644 --- a/internal/providers/groq.go +++ b/internal/providers/groq.go @@ -45,9 +45,11 @@ func (p *GroqProvider) Execute(ctx context.Context, req ai.Request) (*ai.Respons msgs := p.buildMessages(req) body := groqRequest{ - Model: model, - Messages: msgs, - Stream: false, + Model: model, + Messages: msgs, + MaxTokens: req.MaxTokens, + Stop: req.Stop, + Stream: false, } payload, err := json.Marshal(body) @@ -110,9 +112,11 @@ func (p *GroqProvider) ExecuteStream(ctx context.Context, req ai.Request) (io.Re msgs := p.buildMessages(req) body := groqRequest{ - Model: model, - Messages: msgs, - Stream: true, + Model: model, + Messages: msgs, + MaxTokens: req.MaxTokens, + Stop: req.Stop, + Stream: true, } payload, err := json.Marshal(body) @@ -161,9 +165,11 @@ type groqMessage struct { } type groqRequest struct { - Model string `json:"model"` - Messages []groqMessage `json:"messages"` - Stream bool `json:"stream"` + Model string `json:"model"` + Messages []groqMessage `json:"messages"` + MaxTokens int `json:"max_tokens,omitempty"` + Stop []string `json:"stop,omitempty"` + Stream bool `json:"stream"` } type groqResponse struct { diff --git a/internal/providers/ollama.go b/internal/providers/ollama.go index cbb9626..a9029cf 100644 --- a/internal/providers/ollama.go +++ b/internal/providers/ollama.go @@ -142,7 +142,10 @@ func (p *OllamaProvider) Execute(ctx context.Context, req ai.Request) (*ai.Respo msgs := p.buildMessages(req) - maxTokens := 4096 + maxTokens := req.MaxTokens + if maxTokens <= 0 { + maxTokens = 4096 + } body := ollamaRequest{ Model: model, Messages: msgs, @@ -150,7 +153,7 @@ func (p *OllamaProvider) Execute(ctx context.Context, req ai.Request) (*ai.Respo MaxTokens: &maxTokens, Options: &struct { NumPredict int `json:"num_predict"` - }{NumPredict: 4096}, + }{NumPredict: maxTokens}, } if req.ResponseFormat != nil && req.ResponseFormat.Type == "json_object" { body.Format = "json" @@ -225,7 +228,10 @@ func (p *OllamaProvider) ExecuteStream(ctx context.Context, req ai.Request) (io. msgs := p.buildMessages(req) - maxTokens := 4096 + maxTokens := req.MaxTokens + if maxTokens <= 0 { + maxTokens = 4096 + } body := ollamaRequest{ Model: model, Messages: msgs, @@ -233,7 +239,7 @@ func (p *OllamaProvider) ExecuteStream(ctx context.Context, req ai.Request) (io. MaxTokens: &maxTokens, Options: &struct { NumPredict int `json:"num_predict"` - }{NumPredict: 4096}, + }{NumPredict: maxTokens}, } payload, err := json.Marshal(body) @@ -246,11 +252,10 @@ func (p *OllamaProvider) ExecuteStream(ctx context.Context, req ai.Request) (io. return nil, fmt.Errorf("ollama: create request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Accept", "text/event-stream") - httpReq.Header.Set("Cache-Control", "no-cache") if p.apiKey != "" { httpReq.Header.Set("Authorization", "Bearer "+p.apiKey) } + httpReq.Header.Set("Accept", "text/event-stream") resp, err := p.client.Do(httpReq) if err != nil { diff --git a/internal/providers/openai.go b/internal/providers/openai.go index b1c36d2..199d3f3 100644 --- a/internal/providers/openai.go +++ b/internal/providers/openai.go @@ -39,6 +39,8 @@ type openaiMessage struct { type openaiRequest struct { Model string `json:"model"` Messages []openaiMessage `json:"messages"` + MaxTokens int `json:"max_tokens,omitempty"` + Stop []string `json:"stop,omitempty"` Stream bool `json:"stream"` StreamOptions *streamOptions `json:"stream_options,omitempty"` } @@ -95,9 +97,11 @@ func (p *OpenAIProvider) Execute(ctx context.Context, req ai.Request) (*ai.Respo msgs := p.buildMessages(req) body := openaiRequest{ - Model: model, - Messages: msgs, - Stream: false, + Model: model, + Messages: msgs, + MaxTokens: req.MaxTokens, + Stop: req.Stop, + Stream: false, } payload, err := json.Marshal(body) @@ -162,6 +166,8 @@ func (p *OpenAIProvider) ExecuteStream(ctx context.Context, req ai.Request) (io. body := openaiRequest{ Model: model, Messages: msgs, + MaxTokens: req.MaxTokens, + Stop: req.Stop, Stream: true, StreamOptions: &streamOptions{IncludeUsage: true}, } diff --git a/internal/providers/openrouter.go b/internal/providers/openrouter.go index b376398..3f94cc5 100644 --- a/internal/providers/openrouter.go +++ b/internal/providers/openrouter.go @@ -50,9 +50,11 @@ func (p *OpenRouterProvider) Execute(ctx context.Context, req ai.Request) (*ai.R msgs := p.buildMessages(req) body := openrouterRequest{ - Model: model, - Messages: msgs, - Stream: false, + Model: model, + Messages: msgs, + MaxTokens: req.MaxTokens, + Stop: req.Stop, + Stream: false, } payload, err := json.Marshal(body) @@ -117,6 +119,8 @@ func (p *OpenRouterProvider) ExecuteStream(ctx context.Context, req ai.Request) body := openrouterRequest{ Model: model, Messages: msgs, + MaxTokens: req.MaxTokens, + Stop: req.Stop, Stream: true, StreamOptions: &streamOptions{IncludeUsage: true}, } @@ -169,6 +173,8 @@ type openrouterMessage struct { type openrouterRequest struct { Model string `json:"model"` Messages []openrouterMessage `json:"messages"` + MaxTokens int `json:"max_tokens,omitempty"` + Stop []string `json:"stop,omitempty"` Stream bool `json:"stream"` StreamOptions *streamOptions `json:"stream_options,omitempty"` } diff --git a/internal/ui/commands.go b/internal/ui/commands.go index d4dfbe4..dac78ec 100644 --- a/internal/ui/commands.go +++ b/internal/ui/commands.go @@ -1963,7 +1963,7 @@ func resolveHotfixTarget(prompt string) string { // Sanity: must contain a path separator or an extension, and must not // be a single bare word that merely looks like an extension. if strings.Contains(m, "/") || strings.Contains(m, ".") { - return m + return gateway.CanonicalizeFileName(m) } } @@ -2018,13 +2018,16 @@ func (m *model) proposeHotfixPatch(task *plan.Task) tea.Cmd { handoff += "\nModify the above file content to fulfill the task. " handoff += "Output a SEARCH/REPLACE block (`<<<<<<< SEARCH`) or a unified diff. " handoff += "Do NOT output a full FILE: block — the file already exists." + handoff += "\n\nSTRICT: Output ONLY the minimal SEARCH/REPLACE block needed. DO NOT rewrite the entire file. DO NOT include explanations or markdown prose." } system := prompt.BuildContract() req := ai.Request{ - Model: m.cfg.ActiveModelName(), - System: system, - Stream: false, - Messages: []ai.Message{{Role: "user", Content: handoff}}, + Model: m.cfg.ActiveModelName(), + System: system, + Stream: false, + MaxTokens: 500, + Stop: []string{">>>>>>>"}, + Messages: []ai.Message{{Role: "user", Content: handoff}}, } ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) @@ -2043,9 +2046,10 @@ func (m *model) proposeHotfixPatch(task *plan.Task) tea.Cmd { // document and corrupts its syntax, so we sanitize BEFORE the diff is // computed and the patch is staged for disk write. cleaned := sanitizeFileOutput(resp.Content) + resolved := execution.ResolveModifiedContent(orig, resp.Content) // Compute a unified diff for display (green additions / red removals). - diff := computeUnifiedDiff(task.Target, orig, cleaned) + diff := computeUnifiedDiff(task.Target, orig, resolved) patch := &execution.Patch{ ID: fmt.Sprintf("hotfix-%d", task.StepNum), @@ -2139,6 +2143,7 @@ func (m *model) proposeBuildPatch(task *plan.Task) tea.Cmd { baseHandoff += "\nModify the above file content to fulfill the task. " baseHandoff += "Output a SEARCH/REPLACE block (`<<<<<<< SEARCH`) or a unified diff. " baseHandoff += "Do NOT output a full FILE: block — the file already exists." + baseHandoff += "\n\nSTRICT: Output ONLY the minimal SEARCH/REPLACE block needed. DO NOT rewrite the entire file. DO NOT include explanations or markdown prose." } system := prompt.BuildContract() maxRetries := 2 @@ -2146,10 +2151,12 @@ func (m *model) proposeBuildPatch(task *plan.Task) tea.Cmd { for attempt := 0; attempt <= maxRetries; attempt++ { req := ai.Request{ - Model: m.cfg.ActiveModelName(), - System: system, - Stream: false, - Messages: []ai.Message{{Role: "user", Content: handoff}}, + Model: m.cfg.ActiveModelName(), + System: system, + Stream: false, + MaxTokens: 500, + Stop: []string{">>>>>>>", "```\n\n"}, + Messages: []ai.Message{{Role: "user", Content: handoff}}, } ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) @@ -2163,6 +2170,7 @@ func (m *model) proposeBuildPatch(task *plan.Task) tea.Cmd { } cleaned := sanitizeFileOutput(resp.Content) + resolved := execution.ResolveModifiedContent(orig, resp.Content) // Validate patch format BEFORE presenting to human. // If the snippet is ambiguous (no SEARCH/REPLACE markers, no diff @@ -2180,7 +2188,7 @@ func (m *model) proposeBuildPatch(task *plan.Task) tea.Cmd { return buildProposalReadyMsg{Err: fmt.Errorf("%w: ambiguous snippet without SEARCH/REPLACE markers for existing file %s — retry with SEARCH/REPLACE block or unified diff", execution.ErrInvalidPatchFormat, task.Target)} } - diff := computeUnifiedDiff(task.Target, orig, cleaned) + diff := computeUnifiedDiff(task.Target, orig, resolved) patch := &execution.Patch{ ID: fmt.Sprintf("build-%d", task.StepNum), diff --git a/internal/ui/proposals.go b/internal/ui/proposals.go index 676905a..922bbf3 100644 --- a/internal/ui/proposals.go +++ b/internal/ui/proposals.go @@ -12,8 +12,19 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/PizenLabs/izen/internal/execution" + "github.com/PizenLabs/izen/internal/gateway" ) +// searchReplaceBlockRe matches SEARCH/REPLACE blocks that the LLM may emit +// directly (without ``` fences). Each block has the form: +// +// <<<<<<< SEARCH +// +// ======= +// +// >>>>>>> +var searchReplaceBlockRe = regexp.MustCompile(`(?s)<<<<<<< SEARCH\n(.*?)=======\n(.*?)>>>>>>>`) + var diffBlockRegex = regexp.MustCompile("(?s)```diff\\n(.*?)```") // fileTagBlockRegex matches the structured FILE: tag format followed by a code block. @@ -36,6 +47,11 @@ func extractBuildProposals(response string) []SemanticProposal { // PHASE 3: Extract diff blocks. proposals = append(proposals, extractDiffPatches(response)...) + // PHASE 3b: Extract SEARCH/REPLACE blocks and convert to unified diff + // for proper red/green rendering. Must run after diff patches so explicit + // ```diff blocks take priority. + proposals = append(proposals, extractSearchReplaceProposals(response)...) + // PHASE 4: Fallback — if no proposals found, scan for bare code blocks // and try to infer file paths from the response context. if len(proposals) == 0 { @@ -65,6 +81,7 @@ func extractFileTagBlocks(response string) []SemanticProposal { if clean == "" || clean == "." { continue } + clean = gateway.CanonicalizeFileName(clean) diff := body // Safety net: if the file already exists on disk, the model should have @@ -159,7 +176,7 @@ func extractLangPathBlocks(response string) []SemanticProposal { current.Expanded = true clean := filepath.Clean(current.Target.QualifiedName) if clean != "" && clean != "." { - current.Target.QualifiedName = clean + current.Target.QualifiedName = gateway.CanonicalizeFileName(clean) proposals = append(proposals, *current) } } @@ -189,7 +206,7 @@ func extractDiffPatches(response string) []SemanticProposal { if clean != "" && clean != "." { proposals = append(proposals, SemanticProposal{ ID: fmt.Sprintf("build-%d", time.Now().UnixNano()), - Target: SemanticTarget{QualifiedName: clean}, + Target: SemanticTarget{QualifiedName: gateway.CanonicalizeFileName(clean)}, Diff: body, Expanded: true, }) @@ -199,6 +216,56 @@ func extractDiffPatches(response string) []SemanticProposal { return proposals } +// extractSearchReplaceProposals scans for <<<<<<< SEARCH / ======= / >>>>>>> +// blocks that the LLM may emit directly without ``` fences. For each block, it +// infers the target file path from preceding context, reads the original file +// from disk, applies the SEARCH/REPLACE to compute the modified content, and +// builds a unified diff for proper red/green rendering. +func extractSearchReplaceProposals(response string) []SemanticProposal { + if !strings.Contains(response, "<<<<<<< SEARCH") { + return nil + } + var proposals []SemanticProposal + matches := searchReplaceBlockRe.FindAllStringSubmatch(response, -1) + if len(matches) == 0 { + return nil + } + for _, m := range matches { + searchText := strings.TrimSpace(m[1]) + replaceText := strings.TrimSpace(m[2]) + if searchText == "" && replaceText == "" { + continue + } + filePath := findNearestFilePath(response) + if filePath == "" { + continue + } + clean := filepath.Clean(filePath) + if clean == "" || clean == "." { + continue + } + clean = gateway.CanonicalizeFileName(clean) + origBytes, err := os.ReadFile(clean) + if err != nil { + continue + } + orig := string(origBytes) + blocks := execution.ParseSearchReplaceBlocks(response) + modified, ok := execution.ApplySearchReplaceBlocks(orig, blocks) + if !ok || modified == orig { + continue + } + diff := buildSyntheticDiff(clean, orig, modified) + proposals = append(proposals, SemanticProposal{ + ID: fmt.Sprintf("build-sr-%d", time.Now().UnixNano()), + Target: SemanticTarget{QualifiedName: clean}, + Diff: diff, + Expanded: true, + }) + } + return proposals +} + // extractFallbackBlocks is the last-resort parser. When Qwen (or any model) ignores // the structured format and wraps content in bare ```plaintext or ```go blocks, // this function attempts to recover the file content by inferring the target path @@ -232,6 +299,7 @@ func extractFallbackBlocks(response string) []SemanticProposal { if clean == "" || clean == "." { continue } + clean = gateway.CanonicalizeFileName(clean) diff := body // Safety net: if the file already exists on disk, convert to synthetic diff