From f70cbb6bf57302776d72a33c525aecc27b5c15ad Mon Sep 17 00:00:00 2001 From: andev0x Date: Mon, 6 Jul 2026 14:52:38 +0700 Subject: [PATCH 1/2] feat(ui): implement AI interrupt engine - add context cancellation for active streams - modify streamCmd to use context.WithCancel - update model to track interrupt requests - introduce cmdCategory and cmdSection for autocomplete --- internal/ui/model.go | 5 ++ internal/ui/stream.go | 6 +- internal/ui/suggestions.go | 6 ++ internal/ui/update.go | 23 +++++++ internal/ui/view.go | 123 +++++++++++++++++++++++++++++-------- 5 files changed, 137 insertions(+), 26 deletions(-) diff --git a/internal/ui/model.go b/internal/ui/model.go index dfe5415..fa5cb07 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -2,6 +2,7 @@ package ui import ( "bufio" + "context" "encoding/json" "fmt" "math" @@ -253,6 +254,10 @@ type model struct { // Latency telemetry: marked when a turn is submitted, read back when the // stream completes to compute this-turn latency for the status line. streamStartTime time.Time + + // AI Interrupt Engine: cancel function for active stream, set by streamCmd. + streamCancel context.CancelFunc + interruptRequested bool } // ── Rendering helpers ───────────────────────────────────────────────────────── diff --git a/internal/ui/stream.go b/internal/ui/stream.go index ecadb1a..8051613 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -74,10 +74,12 @@ func (m *model) streamCmd(content string) tea.Cmd { Stream: true, } + ctx, cancel := context.WithCancel(context.Background()) + m.streamCancel = cancel + go func() { defer close(m.streamCh) - - ctx, cancel := context.WithCancel(context.Background()) + defer func() { m.streamCancel = nil }() defer cancel() rawStream, err := m.provider.ExecuteStream(ctx, req) diff --git a/internal/ui/suggestions.go b/internal/ui/suggestions.go index 82da7fd..8b9dcb6 100644 --- a/internal/ui/suggestions.go +++ b/internal/ui/suggestions.go @@ -5,6 +5,8 @@ import ( "path/filepath" "sort" "strings" + + "github.com/PizenLabs/izen/internal/modes" ) func (m *model) dismissSuggestions() { @@ -153,6 +155,10 @@ func (m *model) filterCommands(prefix string) []string { } } for _, c := range utilityCommands[currentMode] { + // Strictly hide build-only commands unless in build mode. + if (c == "/undo" || c == "/commit" || c == "/checkpoint") && currentMode != modes.ModeBuild { + continue + } if matches(c) { result = append(result, c) } diff --git a/internal/ui/update.go b/internal/ui/update.go index c743eef..3c917e7 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -444,6 +444,19 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.streamCh = nil m.streaming = false m.streamParser = nil + + // User-initiated interrupt — suppress error noise, just clean up. + if m.interruptRequested { + m.interruptRequested = false + m.responseBuffer.Reset() + m.currentStreamContent = "" + m.streamBuffer = "" + m.streamTickActive = false + m.streamCancel = nil + m.refreshViewportContent() + return m, nil + } + if m.sess.ObjectiveState != nil && m.sess.ObjectiveState.CurrentStatus == domain.ObjectiveExecuting { m.sess.ObjectiveState.CurrentStatus = domain.ObjectivePlanned m.sess.SetObjectiveState(m.sess.ObjectiveState) @@ -477,6 +490,16 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: + // AI INTERRUPT ENGINE: Ctrl+D cancels an active LLM stream. + if m.streaming && msg.Type == tea.KeyCtrlD { + if m.streamCancel != nil { + m.streamCancel() + } + m.interruptRequested = true + m.push(roleSystem, "[System] Generation interrupted by user.") + return m, nil + } + // In special states, route directly to handleKey. if m.state == StateAwaitingApproval || m.state == StateAwaitingShellExec { resModel, cmd := m.handleKey(msg) diff --git a/internal/ui/view.go b/internal/ui/view.go index 7c73f64..77eb576 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -69,6 +69,7 @@ func (m *model) renderStaticBottomControls() string { s.WriteString(modeColor.Render("❯ "+mode.String()) + " ⟩ " + m.ti.View() + "\n") s.WriteString(modeColor.Render(strings.Repeat("─", width)) + "\n") s.WriteString(m.renderRuntimeStatus(width)) + s.WriteString("\n") return s.String() } @@ -85,10 +86,65 @@ func (m *model) modeStyle(mode modes.Mode) lipgloss.Style { // ── Autocomplete Dropdown ────────────────────────────────────────────────── +// ── Command section categories for autocomplete ─────────────────────── + +type cmdCategory int + +const ( + catPrimaryMode cmdCategory = iota + catSystemCommand + catModeContextual +) + +type cmdSection struct { + title string + style lipgloss.Style + items []string +} + +func (m *model) cmdCategoryFor(item string) cmdCategory { + for _, c := range coreModes { + if c == item { + return catPrimaryMode + } + } + for _, c := range globalCommands { + if c == item { + return catSystemCommand + } + } + return catModeContextual +} + +func (m *model) buildCmdSections(items []string) []cmdSection { + var primary, sys, ctx []string + for _, it := range items { + switch m.cmdCategoryFor(it) { + case catPrimaryMode: + primary = append(primary, it) + case catSystemCommand: + sys = append(sys, it) + case catModeContextual: + ctx = append(ctx, it) + } + } + var sections []cmdSection + if len(primary) > 0 { + sections = append(sections, cmdSection{title: "PRIMARY MODES", style: accentStyle, items: primary}) + } + if len(sys) > 0 { + sections = append(sections, cmdSection{title: "SYSTEM COMMANDS", style: subtleStyle, items: sys}) + } + if len(ctx) > 0 { + sections = append(sections, cmdSection{title: "MODE CONTEXTUAL", style: mutedStyle, items: ctx}) + } + return sections +} + // renderAutocompleteDropdown renders a compact border-box suggestion list // positioned directly above the top parallel line. For file selections (@), // it uses a two-column layout with filename on the left and directory on the -// right. Command selections (/) are displayed in a simple single-column list. +// right. Command selections (/) are displayed in categorized section blocks. func (m *model) renderAutocompleteDropdown(width int) string { if len(m.autocompleteItems) == 0 || !m.autocompleteActive { return "" @@ -127,41 +183,55 @@ func (m *model) renderAutocompleteDropdown(width int) string { icon = "▶ " } - leftSide := icon + name - rightSide := dir + " " + // Left column: file name (high contrast) + leftSide := textStyle.Render(icon + name) + // Right column: parent directory (low contrast #6c7086) + rightSide := mutedStyle.Render(dir + " ") - paddingCount := width - lipgloss.Width(leftSide) - lipgloss.Width(rightSide) - 4 + paddingCount := width - lipgloss.Width(icon+name) - lipgloss.Width(dir+" ") - 4 if paddingCount < 0 { paddingCount = 0 } - rowString := leftSide + strings.Repeat(" ", paddingCount) + rightSide if i == m.autocompleteIdx { + rowString := leftSide + strings.Repeat(" ", paddingCount) + rightSide b.WriteString("│ " + highlightedBgStyle.Render(rowString) + " │\n") } else { - b.WriteString("│ " + dimmedStyle.Render(rowString) + " │\n") + b.WriteString("│ " + leftSide + strings.Repeat(" ", paddingCount) + rightSide + " │\n") } } } else { - // Simple single-column layout for commands - for i, item := range list { - display := item - lw := lipgloss.Width(display) - maxContent := width - 8 - if maxContent < 10 { - maxContent = 10 - } - if lw > maxContent { - display = display[:maxContent-1] + "…" - lw = lipgloss.Width(display) - } - pad := strings.Repeat(" ", width-lw-6) + sections := m.buildCmdSections(list) + itemIdx := 0 + for _, sec := range sections { + // Section header + headerStr := " " + sec.style.Render(sec.title) + hPad := width - lipgloss.Width(headerStr) - 4 + if hPad < 0 { + hPad = 0 + } + b.WriteString("│ " + headerStr + strings.Repeat(" ", hPad) + " │\n") + + for _, item := range sec.items { + display := item + lw := lipgloss.Width(display) + maxContent := width - 8 + if maxContent < 10 { + maxContent = 10 + } + if lw > maxContent { + display = display[:maxContent-1] + "…" + lw = lipgloss.Width(display) + } + pad := strings.Repeat(" ", width-lw-6) - rowString := display + pad - if i == m.autocompleteIdx { - b.WriteString("│ " + highlightedBgStyle.Render("▶ "+rowString) + " │\n") - } else { - b.WriteString("│ " + dimmedStyle.Render("◽ "+rowString) + " │\n") + rowString := display + pad + if itemIdx == m.autocompleteIdx { + b.WriteString("│ " + highlightedBgStyle.Render("▶ "+rowString) + " │\n") + } else { + b.WriteString("│ " + dimmedStyle.Render("◽ "+rowString) + " │\n") + } + itemIdx++ } } } @@ -224,6 +294,11 @@ func (m *model) renderRuntimeStatus(width int) string { } b.WriteByte(' ') + // AI INTERRUPT ENGINE: high-visibility indicator when streaming + if m.streaming { + b.WriteString(redStyle.Render("● [Ctrl+D] Interrupt AI ")) + } + // Model name b.WriteString(dimmedStyle.Render(m.cfg.ActiveModelName())) From 9b9f43a5dff2acac269467547fe6bc2185c53f21 Mon Sep 17 00:00:00 2001 From: andev0x Date: Mon, 6 Jul 2026 15:45:59 +0700 Subject: [PATCH 2/2] feat(ui): add runtime mode transition logging - log old and new modes during setMode transition - update viewport after arch rendering - introduce random dev tips in startup banner - improve color consistency across styles --- internal/ui/commands.go | 15 ++++++++++----- internal/ui/model.go | 4 ++++ internal/ui/styles.go | 2 ++ internal/ui/update.go | 8 ++++++++ internal/ui/view.go | 15 +++++++++++++-- 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/internal/ui/commands.go b/internal/ui/commands.go index de1105b..369d55a 100644 --- a/internal/ui/commands.go +++ b/internal/ui/commands.go @@ -333,6 +333,7 @@ func (m *model) setMode(mode modes.Mode) { if mode == m.resolver.Current() { return } + oldMode := m.resolver.Current() m.startModeTransition(mode) m.sess.SetMode(mode) _ = m.sess.Save() @@ -340,6 +341,9 @@ func (m *model) setMode(mode modes.Mode) { modeLabel := lipgloss.NewStyle().Foreground(modeColor).Render( fmt.Sprintf("→ /%s — %s", mode, mode.Description())) m.push(roleSystem, modeLabel) + m.push(roleSystem, fmt.Sprintf("[System] Runtime boundary adjusted: /%s ──> /%s.", oldMode, mode)) + m.refreshViewportContent() + m.Viewport.GotoBottom() } func (m *model) handleCommand(cmd string) tea.Cmd { @@ -464,12 +468,13 @@ func (m *model) handleCommand(cmd string) tea.Cmd { return nil case cmd == "/arch": - m.push(roleSystem, infoStyle.Render("scanning architecture...")) - arch := m.renderArch() - for _, line := range strings.Split(arch, "\n") { - m.push(roleSystem, infoStyle.Render(line)) + m.showBanner = false + m.push(roleSystem, "[System] Reading Graph AST and mapping local repository...") + m.refreshViewportContent() + return func() tea.Msg { + graphText := m.renderArch() + return archDoneMsg{Content: graphText} } - return nil } m.push(roleError, "unknown command: "+cmd) diff --git a/internal/ui/model.go b/internal/ui/model.go index fa5cb07..692a6d6 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -101,6 +101,10 @@ type objectiveAnalyzedMsg struct { err error } +type archDoneMsg struct { + Content string +} + // ── Constants ───────────────────────────────────────────────────────────────── const ( diff --git a/internal/ui/styles.go b/internal/ui/styles.go index a9e7ef7..16072ec 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -17,6 +17,7 @@ const ( colorGreen = "#a6e3a1" colorGreenBr = "#b9f0b4" colorRed = "#f38ba8" + colorMaroon = "#eba0ac" colorOrange = "#fab387" colorYellow = "#f9e2af" colorCyan = "#89dceb" @@ -155,6 +156,7 @@ var ( greenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)) accentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorAccent)) redStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorRed)) + maroonStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMaroon)) blueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorBlue)) // Bold + color diff --git a/internal/ui/update.go b/internal/ui/update.go index 3c917e7..7e84b42 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -163,6 +163,14 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case archDoneMsg: + for _, line := range strings.Split(msg.Content, "\n") { + m.push(roleSystem, infoStyle.Render(line)) + } + m.refreshViewportContent() + m.Viewport.GotoBottom() + return m, nil + case smoothStreamTickMsg: if len(m.streamBuffer) > 0 { // Emit word-aligned chunks for a natural reading rhythm. diff --git a/internal/ui/view.go b/internal/ui/view.go index 77eb576..62ec4d8 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "math/rand" "path/filepath" "strconv" "strings" @@ -296,7 +297,8 @@ func (m *model) renderRuntimeStatus(width int) string { // AI INTERRUPT ENGINE: high-visibility indicator when streaming if m.streaming { - b.WriteString(redStyle.Render("● [Ctrl+D] Interrupt AI ")) + b.WriteString(redStyle.Render("● ")) + b.WriteString(maroonStyle.Render("[Ctrl+D] Interrupt AI ")) } // Model name @@ -326,6 +328,14 @@ func (m *model) renderRuntimeStatus(width int) string { return b.String() } +var devTips = []string{ + "Pro Tip: Press [Esc] three times quickly anywhere to cleanly safely quit IZEN.", + "Pro Tip: Use '@path' to attach files/folders. Multi-column layout automatically isolates parent package names.", + "Pro Tip: IZEN locks execution boundaries. /ask is strictly Read-Only, use /build to run shell mutations.", + "Pro Tip: Run ! to escape the prompt and execute short native shell actions synchronously.", + "Pro Tip: Toggle the global help dashboard overlay instantly by pressing [?] during idle input states.", +} + // ── Startup banner ──────────────────────────────────────────────────── var bannerModes = []struct{ name, desc string }{ @@ -419,7 +429,8 @@ func (m *model) renderStartupBanner(termWidth int) string { metaSep := subtleStyle.Render(" • ") meta := strings.Join(metaParts, metaSep) - rows = append(rows, divider, meta) + tip := mutedStyle.Render(devTips[rand.Intn(len(devTips))]) + rows = append(rows, divider, meta, "", tip) body := strings.Join(rows, "\n") return bannerBorderStyle.Width(termWidth - 2).Render(body)