diff --git a/tui/graph.go b/tui/graph.go
index c6e9996..db52b84 100644
--- a/tui/graph.go
+++ b/tui/graph.go
@@ -144,7 +144,7 @@ func (m model) graphMini(width, height int) string {
func (m model) graphCompact() string {
status := firstNonEmpty(m.graph.status, "missing")
- return m.graphMini(9, 3) + " " + graphStatusStyle(status).Render(strings.ToUpper(status)) +
+ return graphStatusStyle(status).Render("◇ "+strings.ToUpper(status)) +
mutedStyle.Render(fmt.Sprintf(" %d nodes · %d edges", m.graph.nodes, m.graph.edges))
}
@@ -152,7 +152,7 @@ func (m model) graphExplorer() string {
width, height := maxInt(1, m.width-4), maxInt(1, m.height-2)
status := firstNonEmpty(m.graph.status, "missing")
lines := []string{
- brandStyle.Render("PROJECT GRAPH") + " " + graphStatusStyle(status).Render(strings.ToUpper(status)),
+ brandStyle.Render("◆ PROJECT GRAPH") + " " + graphStatusStyle(status).Render(strings.ToUpper(status)),
mutedStyle.Render(fmt.Sprintf("%d nodes · %d edges · %d communities", m.graph.nodes, m.graph.edges, m.graph.communities)),
"",
m.graphMini(minInt(48, maxInt(22, width/2)), minInt(14, maxInt(8, height/3))),
@@ -164,12 +164,14 @@ func (m model) graphExplorer() string {
nodes := m.visibleGraphNodes()
for index, node := range nodes {
marker, style := "·", softStyle
+ rowStyle := lipgloss.NewStyle()
if index == minInt(m.graphSelected, maxInt(0, len(nodes)-1)) {
marker, style = "›", titleStyle
+ rowStyle = rowStyle.Background(lipgloss.Color(surfaceHi)).Padding(0, 1)
}
color := communityColors[node.community%len(communityColors)]
row := lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render("●") + " " + style.Render(compactText(node.label, maxInt(8, width-20)))
- lines = append(lines, marker+" "+row+mutedStyle.Render(fmt.Sprintf(" degree %d", node.degree)))
+ lines = append(lines, rowStyle.Render(marker+" "+row+mutedStyle.Render(fmt.Sprintf(" degree %d", node.degree))))
}
if len(nodes) == 0 {
lines = append(lines, mutedStyle.Render(firstNonEmpty(m.graph.message, "No indexed nodes yet.")))
@@ -200,9 +202,15 @@ func (m model) graphExplorer() string {
}
}
}
- help := "↑↓/hjkl select · +/- zoom · / search · Tab communities · Enter neighbors · p path · r refresh · Esc close"
- lines = append(lines, "", mutedStyle.Render(help))
- return lipgloss.NewStyle().Width(width).MaxWidth(width).Height(height).MaxHeight(height).Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color(cyan)).Padding(0, 1).Render(strings.Join(lines, "\n"))
+ help := "↑↓ select · Enter open · Esc close"
+ if width >= 60 {
+ help = "↑↓ select · / search · Enter neighbors · r refresh · Esc close"
+ }
+ if width >= 100 {
+ help = "↑↓/hjkl select · +/- zoom · / search · Tab communities · Enter neighbors · p path · r refresh · Esc close"
+ }
+ lines = append(lines, "", contextStyle.Copy().Width(maxInt(1, width-2)).Render(mutedStyle.Render(help)))
+ return lipgloss.NewStyle().Background(lipgloss.Color(deep)).Width(width).MaxWidth(width).Height(height).MaxHeight(height).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(cyan)).Padding(0, 1).Render(strings.Join(lines, "\n"))
}
func (m model) visibleGraphNodes() []graphNode {
diff --git a/tui/main.go b/tui/main.go
index 6e07310..df6f8a8 100644
--- a/tui/main.go
+++ b/tui/main.go
@@ -1247,7 +1247,11 @@ func boolValue(values map[string]any, key string) bool {
func (m *model) resize() {
width := m.contentWidth()
mainWidth, _ := m.layoutWidths()
- m.input.SetWidth(width)
+ composerWidth := width
+ if m.height >= 12 {
+ composerWidth = maxInt(1, width-4)
+ }
+ m.input.SetWidth(composerWidth)
m.input.SetHeight(m.composerHeight())
m.apiInput.SetWidth(maxInt(1, minInt(68, m.width-14)))
m.view.SetWidth(mainWidth)
@@ -1262,11 +1266,11 @@ func (m model) contentWidth() int {
func (m model) layoutWidths() (int, int) {
available := m.contentWidth()
- if m.width < 110 {
+ if m.width < 120 || m.height < 24 {
return available, 0
}
railWidth := minInt(30, maxInt(24, available/4))
- return maxInt(1, available-railWidth-2), railWidth
+ return maxInt(1, available-railWidth-1), railWidth
}
func (m model) composerHeight() int {
@@ -1317,9 +1321,6 @@ func (m model) usageSummary() string {
func (m model) chatChromeHeight() int {
width := m.contentWidth()
nonViewport := []string{m.chatHeader()}
- if !m.compactChat() {
- nonViewport = append(nonViewport, rule(width))
- }
if progress := m.progressBlock(width); progress != "" {
nonViewport = append(nonViewport, progress)
}
@@ -1373,7 +1374,20 @@ func (m *model) syncViewport() {
func (m *model) history(width int) string {
if len(m.messages) == 0 {
- return softStyle.Render("No messages yet. Start with a question, or type / for commands.")
+ if width < 28 {
+ return softStyle.Render(compactText("Ready — ask anything", width))
+ }
+ body := strings.Join([]string{
+ brandStyle.Render("◆ WORKSPACE READY"),
+ titleStyle.Render("What will we build?"),
+ mutedStyle.Render("Ask naturally, or start with a focused workflow."),
+ "",
+ softStyle.Render("/plan") + mutedStyle.Render(" plan") + " " +
+ softStyle.Render("/attach") + mutedStyle.Render(" context") + " " +
+ softStyle.Render("/graph") + mutedStyle.Render(" inspect"),
+ }, "\n")
+ boxWidth := maxInt(1, minInt(68, width-8))
+ return welcomeStyle.Copy().Width(boxWidth).MaxWidth(boxWidth).Render(body)
}
var lines []string
for index := range m.messages {
@@ -1394,8 +1408,9 @@ func (m *model) history(width int) string {
body = message.toolDetail
}
}
+ innerWidth := maxInt(1, width-5)
if message.role == "assistant" && body != "" && !message.streaming {
- renderWidth := maxInt(1, width-2)
+ renderWidth := innerWidth
if message.renderedWidth != renderWidth {
message.rendered = renderMarkdown(body, renderWidth)
message.renderedWidth = renderWidth
@@ -1411,10 +1426,27 @@ func (m *model) history(width int) string {
}
body += cursor
}
- block := labelStyle.Render(label) + "\n" + body
- lines = append(lines, lipgloss.NewStyle().Width(maxInt(1, width-2)).Render(block))
+ if width < 12 {
+ lines = append(lines, softStyle.Render(compactText(label+" "+body, width)))
+ continue
+ }
+ block := labelStyle.Render(label)
+ if body != "" {
+ block += "\n" + body
+ }
+ boxWidth := maxInt(1, width-5)
+ style := assistantStyle
+ switch message.role {
+ case "user":
+ style = userStyle
+ case "thinking":
+ style = thinkingStyle
+ case "receipt":
+ style = receiptBoxStyle
+ }
+ lines = append(lines, style.Copy().Width(boxWidth).MaxWidth(boxWidth).Render(block))
}
- return strings.Join(lines, "\n")
+ return strings.Join(lines, "\n\n")
}
func (m model) View() tea.View {
@@ -1489,16 +1521,13 @@ func (m model) chatView() string {
mainWidth, panelWidth := m.layoutWidths()
header := m.chatHeader()
historyHeight := m.historyHeight()
- main := quietStyle.Copy().Width(mainWidth).MaxWidth(mainWidth).Height(historyHeight).MaxHeight(historyHeight).Render(
+ main := lipgloss.NewStyle().Width(mainWidth).MaxWidth(mainWidth).Height(historyHeight).MaxHeight(historyHeight).Render(
limitRows(m.view.View(), historyHeight),
)
if panelWidth > 0 {
- main = lipgloss.JoinHorizontal(lipgloss.Top, main, " ", m.activityRail())
+ main = lipgloss.JoinHorizontal(lipgloss.Top, main, " ", m.activityRail())
}
blocks := []string{header}
- if !m.compactChat() {
- blocks = append(blocks, rule(contentWidth))
- }
blocks = append(blocks, main)
if progress := m.progressBlock(contentWidth); progress != "" {
blocks = append(blocks, progress)
@@ -1513,52 +1542,88 @@ func (m model) chatView() string {
func (m model) chatHeader() string {
width := m.contentWidth()
- compact := m.width < 80 || m.height < 16
- modelLabel := firstNonEmpty(m.provider, "provider pending") + " · " + firstNonEmpty(m.modelName, "model pending")
+ if width < 4 {
+ return brandStyle.Render(compactText("K", width))
+ }
+ innerWidth := maxInt(1, width-2)
+ brand := "◆ OPENKYROZEN"
+ if innerWidth < 24 {
+ brand = "◆ KYROZEN"
+ }
+ left := brandStyle.Render(compactText(brand, innerWidth))
mode := strings.ToUpper(firstNonEmpty(m.effectiveMode, m.interactionMode, "ask"))
- if m.height > 0 && m.height < 12 {
- return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(
- brandStyle.Render("OPENKYROZEN") + " " + mutedStyle.Render(mode),
- )
+
+ statusText := compactText(firstNonEmpty(m.status, "Ready"), maxInt(4, minInt(18, innerWidth/3)))
+ status := mutedStyle.Render("● " + statusText)
+ if m.busy {
+ status = amberStyle.Render(taskSpinner(m.motionFrame) + " " + statusText)
+ } else if strings.EqualFold(statusText, "ready") {
+ status = greenStyle.Render("● READY")
}
- lines := []string{
- brandStyle.Render("OPENKYROZEN") + " " + mutedStyle.Render(mode),
- softStyle.Render("MODEL ") + softStyle.Render(compactText(modelLabel, width-8)),
- softStyle.Render("USAGE ") + softStyle.Render(compactText(m.usageSummary(), width-8)),
+ right := ""
+ leftBudget := innerWidth
+ if innerWidth >= 38 {
+ right = status
+ leftBudget = maxInt(1, innerWidth-lipgloss.Width(right)-2)
}
- if !compact && m.workspace != "" {
- lines = append(lines, mutedStyle.Render("WORKSPACE "+compactText(m.workspace, width-11)))
+ add := func(item string) {
+ candidate := left + mutedStyle.Render(" · ") + item
+ if lipgloss.Width(candidate) <= leftBudget {
+ left = candidate
+ }
}
+ add(badgeStyle.Render(mode))
_, panelWidth := m.layoutWidths()
- if !compact && panelWidth == 0 && m.height >= 18 {
- lines = append(lines, m.graphCompact())
+ if panelWidth == 0 {
+ graphStatus := strings.ToUpper(firstNonEmpty(m.graph.status, "missing"))
+ add(graphStatusStyle(graphStatus).Render("◇ " + graphStatus))
+ }
+ if m.modelName != "" {
+ add(softStyle.Render(compactText(m.modelName, 22)))
+ }
+ tokens := m.usagePromptTokens + m.usageCompletionTokens + m.usageReasoningTokens
+ usage := formatUsageTokens(tokens) + " TOK"
+ if m.usageCostPicos > 0 {
+ usage += " · " + formatUsageCost(m.usageCostPicos)
}
- return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(strings.Join(lines, "\n"))
+ add(mutedStyle.Render(usage))
+
+ row := left
+ if right != "" {
+ gap := maxInt(2, innerWidth-lipgloss.Width(left)-lipgloss.Width(right))
+ row += strings.Repeat(" ", gap) + right
+ }
+ return contextStyle.Copy().Width(innerWidth).MaxWidth(innerWidth).Render(row)
}
func (m model) chatFooter(width int) string {
- if width < 80 || m.height < 18 {
- status := compactText(firstNonEmpty(m.status, "Ready"), maxInt(4, width/4))
- return mutedStyle.Copy().Width(width).MaxWidth(width).Render(
- compactText("○ "+status+" · ↵ send · / commands · ^C quit", width),
- )
+ if width < 4 {
+ return ""
}
- hints := "Enter send · ↑↓ commands · PgUp/PgDn scroll · Ctrl+C quit"
- if width >= 80 && m.height >= 18 {
- hints = "Enter send · Shift+Enter newline · ↑↓ commands · PgUp/PgDn scroll · Ctrl+C quit"
+ hints := "↵ send · / commands · ^C quit"
+ if width >= 72 && m.height >= 18 {
+ hints = "Enter send · Shift+Enter newline · / commands · PgUp/PgDn scroll · Ctrl+C quit"
}
- status := mutedStyle.Render("○ " + firstNonEmpty(m.status, "Ready"))
- if m.busy {
- status = amberStyle.Render(taskSpinner(m.motionFrame) + " " + firstNonEmpty(m.status, "Working…"))
+ workspace := ""
+ if m.workspace != "" && width >= 52 {
+ workspace = compactText(filepath.Base(m.workspace), maxInt(8, width/4))
+ }
+ if workspace == "" {
+ return mutedStyle.Copy().Width(width).MaxWidth(width).Render(compactText(hints, width))
}
- return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(status + " " + mutedStyle.Render(hints))
+ right := compactText(hints, maxInt(1, width-lipgloss.Width(workspace)-2))
+ gap := maxInt(2, width-lipgloss.Width(workspace)-lipgloss.Width(right))
+ return mutedStyle.Copy().Width(width).MaxWidth(width).Render(workspace + strings.Repeat(" ", gap) + right)
}
func (m model) paletteView(width int) string {
if len(m.palette) == 0 {
return ""
}
- rowWidth := maxInt(1, width-2)
+ if width < 12 {
+ return titleStyle.Render(compactText("COMMANDS", width))
+ }
+ rowWidth := maxInt(1, width-6)
visible := maxInt(1, minInt(len(m.palette), 5))
start := 0
if m.paletteIndex >= visible {
@@ -1569,7 +1634,7 @@ func (m model) paletteView(width int) string {
if len(m.palette) > visible {
title += " · ↑↓ MORE"
}
- lines := []string{titleStyle.Render(title)}
+ lines := []string{sectionStyle.Render(title)}
for index := start; index < end; index++ {
item := m.palette[index]
cursor := mutedStyle.Render("·")
@@ -1581,19 +1646,22 @@ func (m model) paletteView(width int) string {
cursor = brandStyle.Render("»")
}
}
- row := cursor + " " + softStyle.Render("/"+item.name) + " " + mutedStyle.Render(item.description)
+ descriptionWidth := maxInt(1, rowWidth-len(item.name)-5)
+ row := cursor + " " + titleStyle.Render("/"+item.name) + " " + mutedStyle.Render(compactText(item.description, descriptionWidth))
lines = append(lines, rowStyle.MaxWidth(rowWidth).Render(row))
}
- return strings.Join(lines, "\n")
+ panelWidth := maxInt(1, width-4)
+ return paletteStyle.Copy().Width(panelWidth).MaxWidth(panelWidth).Render(strings.Join(lines, "\n"))
}
func (m model) composer(width int) string {
if m.height > 0 && m.height < 12 {
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(m.input.View())
}
- style := quietStyle.Copy().Width(width).MaxWidth(width).BorderTop(true).BorderBottom(true).BorderForeground(lipgloss.Color(border))
+ panelWidth := maxInt(1, width-4)
+ style := composerStyle.Copy().Width(panelWidth).MaxWidth(panelWidth)
if m.input.Focused() {
- style = style.BorderBottomForeground(lipgloss.Color(cyan))
+ style = style.BorderForeground(lipgloss.Color(cyan))
}
return style.Render(m.input.View())
}
@@ -1603,59 +1671,65 @@ func (m model) progressBlock(width int) string {
return ""
}
message := firstNonEmpty(m.thinkingText, m.status, "Working…")
- return lipgloss.NewStyle().Width(width).Render(amberStyle.Render(taskSpinner(m.motionFrame)) + " " + softStyle.Render(message))
+ innerWidth := maxInt(1, width-2)
+ line := amberStyle.Render(taskSpinner(m.motionFrame)) + " " + softStyle.Render(compactText(message, maxInt(1, innerWidth-3)))
+ return contextStyle.Copy().Width(innerWidth).MaxWidth(innerWidth).Render(line)
}
func (m model) activityRail() string {
_, panelWidth := m.layoutWidths()
width := maxInt(1, panelWidth-2)
+ textWidth := maxInt(1, width-2)
height := m.historyHeight()
- compact := height < 22
graphStatus := firstNonEmpty(m.graph.status, "missing")
- lines := []string{titleStyle.Render("ACTIVITY")}
- if !compact {
- lines = []string{
- titleStyle.Render("PROJECT GRAPH") + " " + graphStatusStyle(graphStatus).Render(strings.ToUpper(graphStatus)),
- m.graphMini(minInt(22, width), 8),
- mutedStyle.Render(fmt.Sprintf("%d nodes · %d edges", m.graph.nodes, m.graph.edges)),
- "", titleStyle.Render("ACTIVITY"), rule(width), mutedStyle.Render("PROVIDER"), softStyle.Render(firstNonEmpty(m.provider, "pending")),
- }
- } else {
- lines = append(lines, rule(width), mutedStyle.Render("PROVIDER"), softStyle.Render(firstNonEmpty(m.provider, "pending")))
- }
- if m.modelName != "" {
- lines = append(lines, mutedStyle.Render(compactText(m.modelName, width)))
+ mapHeight := 3
+ if height >= 22 {
+ mapHeight = 5
}
- if m.workspace != "" && !compact {
- lines = append(lines, "", mutedStyle.Render("WORKSPACE"), softStyle.Render(compactText(m.workspace, width)))
- }
- lines = append(lines, "", mutedStyle.Render("USAGE"), softStyle.Render(m.usageSummary()))
- if !compact {
- lines = append(lines, "", mutedStyle.Render("MODE"), softStyle.Render(
- "preference "+firstNonEmpty(m.interactionMode, "auto")+" · active "+firstNonEmpty(m.effectiveMode, "ask"),
- ))
- lines = append(lines, "", titleStyle.Render(fmt.Sprintf("TASKS %d", len(m.tasks))))
- } else {
- lines = append(lines, "", titleStyle.Render(fmt.Sprintf("TASKS %d", len(m.tasks))))
+ lines := []string{
+ sectionStyle.Render("PROJECT GRAPH") + " " + graphStatusStyle(graphStatus).Render(strings.ToUpper(graphStatus)),
+ m.graphMini(minInt(22, textWidth), mapHeight),
+ mutedStyle.Render(fmt.Sprintf("%d nodes · %d edges", m.graph.nodes, m.graph.edges)),
+ "",
+ sectionStyle.Render(fmt.Sprintf("TASKS %d", len(m.tasks))),
}
if len(m.tasks) == 0 {
lines = append(lines, mutedStyle.Render("No active tasks"))
}
- for _, task := range m.tasks {
+ maxTasks := maxInt(1, height-mapHeight-12)
+ for index, task := range m.tasks {
+ if index >= maxTasks {
+ lines = append(lines, mutedStyle.Render(fmt.Sprintf("+%d more", len(m.tasks)-index)))
+ break
+ }
icon, stateStyle, label := taskState(task.status, m.motionFrame)
if task.id == m.taskFlashID && m.taskFlashTick > 0 && task.status == "succeeded" {
label = "completed ·"
}
- if compact {
- lines = append(lines, stateStyle.Render(icon)+" "+softStyle.Render(compactText(task.description, width-12))+" "+stateStyle.Render(label))
- } else {
- lines = append(lines, stateStyle.Render(icon)+" "+softStyle.Render(compactText(task.description, width-4)), stateStyle.Render(label))
- }
+ row := stateStyle.Render(icon) + " " + softStyle.Render(compactText(task.description, maxInt(4, textWidth-lipgloss.Width(label)-4))) + " " + stateStyle.Render(label)
+ lines = append(lines, row)
+ }
+ workspace := "global workspace"
+ if m.workspace != "" {
+ workspace = filepath.Base(m.workspace)
+ }
+ usage := "No usage yet"
+ if m.usageAttempts > 0 {
+ usage = fmt.Sprintf("%d calls · %s", m.usageAttempts, firstNonEmpty(m.usageScope, "workspace"))
+ }
+ lines = append(lines, "", sectionStyle.Render("SESSION"), softStyle.Render(compactText(workspace, textWidth)), mutedStyle.Render(compactText(usage, textWidth)))
+ preferredMode := firstNonEmpty(m.interactionMode, "auto")
+ activeMode := firstNonEmpty(m.effectiveMode, "ask")
+ if m.interactionMode != "" && m.effectiveMode != "" && preferredMode != activeMode {
+ lines = append(lines,
+ mutedStyle.Render(compactText("preference "+preferredMode, textWidth)),
+ mutedStyle.Render(compactText("active "+activeMode, textWidth)),
+ )
}
// Keep the rail inside the transcript row budget. Without this cap,
// JoinHorizontal adopts a task-heavy rail's natural height and pushes the
// composer/footer below the terminal viewport.
- return lipgloss.NewStyle().Width(maxInt(1, panelWidth)).MaxWidth(maxInt(1, panelWidth)).Height(height).MaxHeight(height).BorderLeft(true).BorderForeground(lipgloss.Color(border)).PaddingLeft(2).Render(limitRows(strings.Join(lines, "\n"), height))
+ return activityStyle.Copy().Width(width).MaxWidth(width).Height(height).MaxHeight(height).Render(limitRows(strings.Join(lines, "\n"), height))
}
func (m model) taskPanel() string { return m.activityRail() }
diff --git a/tui/main_test.go b/tui/main_test.go
index bd4cd2f..a13730c 100644
--- a/tui/main_test.go
+++ b/tui/main_test.go
@@ -148,9 +148,12 @@ func TestUsageEventAppearsInChatAndSettings(t *testing.T) {
})
m.resize()
chat := m.View().Content
- if !strings.Contains(chat, "MODEL") || !strings.Contains(chat, "deepseek-chat") || !strings.Contains(chat, "USAGE") || !strings.Contains(chat, "$0.01") {
+ if !strings.Contains(chat, "deepseek-chat") || !strings.Contains(chat, "2.1K TOK") || !strings.Contains(chat, "$0.01") {
t.Fatalf("chat header omitted model or usage summary: %s", chat)
}
+ if got := lipgloss.Height(m.chatHeader()); got != 1 {
+ t.Fatalf("context bar rendered %d rows, want 1", got)
+ }
m.screen = screenSettings
settings := m.View().Content
if !strings.Contains(settings, "SESSION") || !strings.Contains(settings, "MEMORY") || !strings.Contains(settings, "workspace") {
@@ -437,7 +440,7 @@ func TestToolReceiptsKeepSemanticStatus(t *testing.T) {
func TestTaskStateTransitionAndAdaptiveRail(t *testing.T) {
m := initialModel("", false)
m.reducedMotion = false
- m.width = 140
+ m.width, m.height = 140, 30
m.tasks = []taskItem{{id: "t1", description: "Build", status: "running"}}
if _, rail := m.layoutWidths(); rail == 0 {
t.Fatal("wide layout did not allocate an activity rail")
@@ -455,6 +458,82 @@ func TestTaskStateTransitionAndAdaptiveRail(t *testing.T) {
}
}
+func TestFocusedCockpitRailBreakpointAndCompactGraph(t *testing.T) {
+ m := initialModel("", true)
+ m.graph = graphSnapshot{status: "ready", nodes: 12, edges: 18}
+ for _, size := range [][2]int{{119, 40}, {120, 23}} {
+ m.width, m.height = size[0], size[1]
+ if _, rail := m.layoutWidths(); rail != 0 {
+ t.Fatalf("terminal %dx%d unexpectedly rendered a rail", size[0], size[1])
+ }
+ if header := m.chatHeader(); !strings.Contains(header, "READY") {
+ t.Fatalf("terminal %dx%d lost compact graph state: %s", size[0], size[1], header)
+ }
+ }
+ m.width, m.height = 120, 24
+ if _, rail := m.layoutWidths(); rail == 0 {
+ t.Fatal("120x24 did not enable the contextual rail")
+ }
+ if header := m.chatHeader(); strings.Contains(header, "◇ READY") {
+ t.Fatalf("wide header duplicated graph telemetry: %s", header)
+ }
+}
+
+func TestFocusedCockpitMessageHierarchyAndEmptyState(t *testing.T) {
+ m := initialModel("", true)
+ empty := m.history(80)
+ if !strings.Contains(empty, "WORKSPACE READY") || !strings.Contains(empty, "/plan") || !strings.Contains(empty, "/graph") {
+ t.Fatalf("empty state is incomplete: %s", empty)
+ }
+ m.messages = []chatMessage{
+ {role: "user", text: "Review this"},
+ {role: "assistant", text: "Working **carefully**.", streaming: true},
+ {role: "thinking", text: "Inspecting the graph"},
+ {role: "receipt", toolAction: "read_file", status: "success", toolDetail: "private details"},
+ }
+ history := m.history(80)
+ for _, label := range []string{"YOU", "KYROZEN", "THINKING", "TOOL · read_file · SUCCESS", "▌"} {
+ if !strings.Contains(history, label) {
+ t.Fatalf("message hierarchy omitted %q: %s", label, history)
+ }
+ }
+ if strings.Contains(history, "private details") {
+ t.Fatalf("collapsed tool receipt exposed details: %s", history)
+ }
+}
+
+func TestFocusedCockpitDoesNotDuplicateModelTelemetry(t *testing.T) {
+ m := initialModel("/tmp/workspace", true)
+ m.width, m.height, m.screen = 140, 40, screenChat
+ m.modelName = "deepseek-v4-flash"
+ m.usageScope, m.usageAttempts = "workspace", 4
+ m.usagePromptTokens, m.usageCompletionTokens, m.usageReasoningTokens = 5400, 1800, 620
+ m.resize()
+ view := m.View().Content
+ if got := strings.Count(view, "deepseek-v4-flash"); got != 1 {
+ t.Fatalf("model telemetry appeared %d times, want once: %s", got, view)
+ }
+ if got := strings.Count(view, "7.8K TOK"); got != 1 || !strings.Contains(view, "4 calls · workspace") {
+ t.Fatalf("session usage hierarchy is incomplete or duplicated: %s", view)
+ }
+}
+
+func TestFocusedCockpitPaletteAndModalUseUnifiedFrame(t *testing.T) {
+ m := initialModel("", true)
+ m.palette = commandMatches("/")
+ palette := m.paletteView(80)
+ if !strings.Contains(palette, "╭") || !strings.Contains(palette, "COMMANDS") {
+ t.Fatalf("command palette lost the cockpit frame: %s", palette)
+ }
+ m.width, m.height, m.screen = 80, 24, screenApproval
+ m.approvalTool = "run_cmd"
+ m.resize()
+ modal := m.View().Content
+ if !strings.Contains(modal, "╭") || !strings.Contains(modal, "APPROVAL REQUIRED") {
+ t.Fatalf("approval modal lost the cockpit frame: %s", modal)
+ }
+}
+
func TestRenderedChatFitsSmallWindow(t *testing.T) {
m := initialModel("", false)
m.width, m.height = 60, 16
diff --git a/tui/screenshots/focused-cockpit-after.svg b/tui/screenshots/focused-cockpit-after.svg
new file mode 100644
index 0000000..47ccd4c
--- /dev/null
+++ b/tui/screenshots/focused-cockpit-after.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tui/screenshots/focused-cockpit-before.svg b/tui/screenshots/focused-cockpit-before.svg
new file mode 100644
index 0000000..90875de
--- /dev/null
+++ b/tui/screenshots/focused-cockpit-before.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tui/theme.go b/tui/theme.go
index 15a7d22..c364275 100644
--- a/tui/theme.go
+++ b/tui/theme.go
@@ -10,18 +10,18 @@ import (
)
const (
- ink = "#000000"
- deep = "#080B0E"
- surface = "#141A1F"
- surfaceHi = "#202A31"
- border = "#53616B"
- white = "#FFFFFF"
- offWhite = "#E7EDF1"
- muted = "#AAB6BE"
- cyan = "#00F0FF"
- green = "#55D187"
- amber = "#F0B35A"
- red = "#F4778D"
+ ink = "#060A0D"
+ deep = "#0B1117"
+ surface = "#111A22"
+ surfaceHi = "#192632"
+ border = "#587180"
+ white = "#F6FBFD"
+ offWhite = "#DBE7EC"
+ muted = "#A9BBC4"
+ cyan = "#2DE2E6"
+ green = "#7CE38B"
+ amber = "#F6C177"
+ red = "#F38BA8"
)
var (
@@ -33,7 +33,19 @@ var (
ruleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(border))
quietStyle = lipgloss.NewStyle().Background(lipgloss.Color(deep)).Padding(0, 1)
focusStyle = lipgloss.NewStyle().Background(lipgloss.Color(surface)).BorderBottom(true).BorderForeground(lipgloss.Color(cyan)).Padding(0, 1)
- modalStyle = lipgloss.NewStyle().Background(lipgloss.Color(surface)).Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color(border)).Padding(1, 2)
+ modalStyle = lipgloss.NewStyle().Background(lipgloss.Color(surface)).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(border)).Padding(1, 2)
+
+ contextStyle = lipgloss.NewStyle().Background(lipgloss.Color(deep))
+ badgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(cyan)).Background(lipgloss.Color(surfaceHi)).Bold(true).Padding(0, 1)
+ sectionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(muted)).Bold(true)
+ welcomeStyle = lipgloss.NewStyle().Background(lipgloss.Color(deep)).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(border)).Padding(1, 2)
+ assistantStyle = lipgloss.NewStyle().BorderLeft(true).BorderForeground(lipgloss.Color(border)).PaddingLeft(1)
+ userStyle = lipgloss.NewStyle().Background(lipgloss.Color(surface)).BorderLeft(true).BorderForeground(lipgloss.Color(cyan)).Padding(0, 1)
+ thinkingStyle = lipgloss.NewStyle().BorderLeft(true).BorderForeground(lipgloss.Color(amber)).PaddingLeft(1)
+ receiptBoxStyle = lipgloss.NewStyle().Background(lipgloss.Color(deep)).Padding(0, 1)
+ paletteStyle = lipgloss.NewStyle().Background(lipgloss.Color(surface)).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(border)).Padding(0, 1)
+ composerStyle = lipgloss.NewStyle().Background(lipgloss.Color(deep)).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(border)).Padding(0, 1)
+ activityStyle = lipgloss.NewStyle().Background(lipgloss.Color(deep)).BorderLeft(true).BorderForeground(lipgloss.Color(border)).PaddingLeft(1)
amberStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(amber))
greenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(green))
diff --git a/tui/theme_test.go b/tui/theme_test.go
index 6826713..d0da158 100644
--- a/tui/theme_test.go
+++ b/tui/theme_test.go
@@ -48,6 +48,19 @@ func TestThemeContrastAgainstBlackCanvas(t *testing.T) {
}
}
+func TestThemeContrastAcrossCockpitSurfaces(t *testing.T) {
+ for _, background := range []string{deep, surface, surfaceHi} {
+ for _, foreground := range []string{white, offWhite, muted, cyan, green, amber, red} {
+ if ratio := contrast(foreground, background); ratio < 4.5 {
+ t.Fatalf("%s has insufficient contrast on %s: %.2f", foreground, background, ratio)
+ }
+ }
+ }
+ if ratio := contrast(border, surface); ratio < 3 {
+ t.Fatalf("border contrast is too low on the primary surface: %.2f", ratio)
+ }
+}
+
func TestMarkdownRolesKeepCyanForEmphasis(t *testing.T) {
if *markdownStyles.Document.Color != white || *markdownStyles.Text.Color != white || *markdownStyles.Paragraph.Color != white {
t.Fatal("ordinary markdown text is not white-first")