Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions internal/ui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,13 +333,17 @@ 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()
modeColor := modeAccentColor(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 {
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions internal/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package ui

import (
"bufio"
"context"
"encoding/json"
"fmt"
"math"
Expand Down Expand Up @@ -100,6 +101,10 @@ type objectiveAnalyzedMsg struct {
err error
}

type archDoneMsg struct {
Content string
}

// ── Constants ─────────────────────────────────────────────────────────────────

const (
Expand Down Expand Up @@ -253,6 +258,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 ─────────────────────────────────────────────────────────
Expand Down
6 changes: 4 additions & 2 deletions internal/ui/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions internal/ui/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const (
colorGreen = "#a6e3a1"
colorGreenBr = "#b9f0b4"
colorRed = "#f38ba8"
colorMaroon = "#eba0ac"
colorOrange = "#fab387"
colorYellow = "#f9e2af"
colorCyan = "#89dceb"
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions internal/ui/suggestions.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"path/filepath"
"sort"
"strings"

"github.com/PizenLabs/izen/internal/modes"
)

func (m *model) dismissSuggestions() {
Expand Down Expand Up @@ -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)
}
Expand Down
31 changes: 31 additions & 0 deletions internal/ui/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -444,6 +452,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)
Expand Down Expand Up @@ -477,6 +498,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)
Expand Down
134 changes: 110 additions & 24 deletions internal/ui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package ui

import (
"fmt"
"math/rand"
"path/filepath"
"strconv"
"strings"
Expand Down Expand Up @@ -69,6 +70,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()
}
Expand All @@ -85,10 +87,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 ""
Expand Down Expand Up @@ -127,41 +184,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)
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
}
pad := strings.Repeat(" ", width-lw-6)
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++
}
}
}
Expand Down Expand Up @@ -224,6 +295,12 @@ func (m *model) renderRuntimeStatus(width int) string {
}
b.WriteByte(' ')

// AI INTERRUPT ENGINE: high-visibility indicator when streaming
if m.streaming {
b.WriteString(redStyle.Render("● "))
b.WriteString(maroonStyle.Render("[Ctrl+D] Interrupt AI "))
}

// Model name
b.WriteString(dimmedStyle.Render(m.cfg.ActiveModelName()))

Expand Down Expand Up @@ -251,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 !<command> 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 }{
Expand Down Expand Up @@ -344,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)
Expand Down
Loading