Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8e65c74
refactor(tui): replace emoji icons with ASCII alternatives
entr-agent Feb 27, 2026
f60016d
fix(tui): correct viewport height reservation for scroll-lock indicator
entr-agent Feb 27, 2026
643a3f9
refactor(tui): redesign overlays with responsive sizing and flat visu…
entr-agent Feb 27, 2026
1fa2c0a
docs(tui): add slash menu discoverability redesign scratch doc and pa…
entr-agent Feb 27, 2026
7513e2a
feat(tui): add command palette scrolling and adjust toast overlay pos…
entr-agent Feb 27, 2026
c38ac83
feat(tui): execute selected command immediately on enter
entr-agent Feb 27, 2026
5d055c4
feat(tui): dynamically adjust textarea height based on terminal size
justinwilkin Feb 27, 2026
b53eb60
feat(tools): add unique ID to tool calls and related events
entr-agent Feb 27, 2026
2dafcf0
feat(tui): simplify context summarization by removing progress tracking
justinwilkin Feb 27, 2026
f1f4217
feat(tui): improve output formatting, sanitization, and error display
entr-agent Feb 27, 2026
683c059
feat(tui): add collapsible thinking blocks with elapsed time indicator
entr-agent Feb 27, 2026
483ce99
fix(tui): remove extra newlines in summarization status rendering
entr-agent Feb 27, 2026
35663be
feat(tui): add show thinking toggle setting to UI config and settings…
entr-agent Feb 27, 2026
dcb360a
style(tui): normalize whitespace and simplify overlay code
entr-agent Feb 27, 2026
fad261e
docs(readme): update TUI feature list and add guides for redesigned i…
entr-agent Feb 27, 2026
578123c
feat(tui): redesign header bar and centralize version constant
entr-agent Mar 1, 2026
edda260
fix(tui): account for visual spacer line in viewport height calculation
entr-agent Mar 2, 2026
475cc7e
refactor(tui): simplify message rendering and improve scroll logic
entr-agent Mar 2, 2026
179cf4f
feat(tui): update user icon style color from salmon pink to coral pink
justinwilkin Mar 2, 2026
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
23 changes: 15 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,16 @@ func main() {

### 🖥️ Advanced Terminal UI

- **Syntax Highlighting**: Full syntax highlighting for diffs and code blocks
- **Diff Viewer**: Interactive unified diff viewer with color-coded changes
- **Command Palette**: Quick access to settings, slash commands, and actions (Ctrl+P)
- **Interactive Settings**: Live configuration of auto-approval, model parameters, and system behavior
- **Slash Commands**: Built-in commands for common workflows (`/commit`, `/pr`, `/clear`, `/help`)
- **Real-time Streaming**: See agent thinking, tool calls, and responses as they happen
- **Real-time Streaming**: See agent thinking, tool calls, and responses stream in as they happen
- **Compact Header Bar**: Workspace path and active model shown at a glance; context-aware hints update with TUI state
- **Smart Scroll-Lock**: Scroll up to review history while the agent works; a banner appears when new content arrives, press `G` to jump back and resume auto-follow
- **Command Palette**: Instant slash command launcher via `Ctrl+K` / `Ctrl+P` or typing `/`; `Enter` executes immediately
- **Diff Viewer**: Interactive unified diff viewer with syntax highlighting for file changes
- **Tool Result History**: Browse all tool results from the session with `Ctrl+L`; inspect the latest result with `Ctrl+V`
- **Clipboard Copy**: Copy the full conversation as plain text with `Ctrl+Y`
- **Agent Thinking Blocks**: Extended reasoning shown inline with elapsed time; toggle visibility in settings
- **Interactive Settings**: Live configuration of LLM parameters, auto-approval rules, and UI preferences
- **Slash Commands**: Built-in commands for common workflows (`/commit`, `/pr`, `/bash`, `/notes`, `/help`, `/snapshot`)

### 🛠️ Complete Coding Toolkit

Expand Down Expand Up @@ -130,6 +134,7 @@ func main() {
- [Understanding the Agent Loop](docs/getting-started/understanding-agent-loop.md) - Core concepts

### How-To Guides
- [Use TUI Interface](docs/how-to/use-tui-interface.md) - Full TUI guide (scroll-lock, clipboard, thinking blocks, overlays)
- [Configure Provider](docs/how-to/configure-provider.md) - LLM provider setup
- [Create Custom Tools](docs/how-to/create-custom-tool.md) - Extend agent capabilities
- [Setup PR Documentation](docs/how-to/setup-pr-documentation.md) - Automated docs workflow
Expand Down Expand Up @@ -157,9 +162,11 @@ func main() {

### Architecture Decision Records
See [ADRs](docs/adr/) for detailed design decisions including:
- [TUI Visual Redesign](docs/adr/0051-tui-visual-redesign.md)
- [TUI Clipboard Copy](docs/adr/0050-tui-clipboard-copy.md)
- [TUI Bracketed Paste](docs/adr/0049-tui-bracketed-paste-support.md)
- [TUI Smart Scroll-Lock](docs/adr/0048-tui-smart-scroll-lock.md)
- [Automated PR Documentation](docs/adr/0030-automated-pr-documentation.md)
- [Headless Git PR Creation](docs/adr/0031-headless-git-pr-creation.md)
- [Headless Git Integration](docs/adr/0029-headless-git-integration.md)
- [XML Tool Call Format](docs/adr/0019-xml-cdata-tool-call-format.md)
- [Auto-Approval System](docs/adr/0017-auto-approval-and-settings-system.md)
- [Context Management](docs/adr/0014-composable-context-management.md)
Expand Down
3 changes: 2 additions & 1 deletion cmd/forge/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/entrhq/forge/pkg/executor/tui"
"github.com/entrhq/forge/pkg/llm"
"github.com/entrhq/forge/pkg/llm/openai"
frameworkVersion "github.com/entrhq/forge/pkg/version"

"github.com/entrhq/forge/pkg/security/workspace"
"github.com/entrhq/forge/pkg/tools/browser"
Expand All @@ -33,7 +34,7 @@ import (
)

const (
version = "0.1.0" // Version of the Forge coding agent
version = frameworkVersion.Version // Version of the Forge coding agent
defaultModel = "anthropic/claude-sonnet-4.5" // Default model to use

// Context management defaults for coding sessions
Expand Down
4 changes: 2 additions & 2 deletions docs/adr/0010-tool-approval-mechanism.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,8 @@ For example, `ApplyDiffTool.Preview()` returns formatted diff output.

- [ADR-0008](0008-agent-controlled-loop-termination.md) - Agent loop control flow
- [ADR-0009](0009-tui-executor-design.md) - TUI executor architecture
- ADR-0011 (upcoming) - Coding tools architecture
- ADR-0012 (upcoming) - Enhanced TUI diff viewer
- [ADR-0011](0011-coding-tools-architecture.md) - Coding tools architecture
- [ADR-0012](0012-enhanced-tui-executor.md) - Enhanced TUI diff viewer

---

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0011-coding-tools-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ Tools like `ApplyDiffTool` and `WriteFileTool` implement this to show diffs/chan
## Related Decisions

- [ADR-0010](0010-tool-approval-mechanism.md) - Tool approval flow
- ADR-0012 (upcoming) - Enhanced TUI with diff viewer
- [ADR-0012](0012-enhanced-tui-executor.md) - Enhanced TUI with diff viewer
- [ADR-0009](0009-tui-executor-design.md) - TUI executor architecture

---
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0012-enhanced-tui-executor.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 12. Enhanced TUI Executor with Diff Viewer

**Status:** Proposed
**Status:** Implemented
**Date:** 2025-01-05
**Deciders:** Forge Core Team
**Technical Story:** Extending the TUI executor to support coding workflows with diff preview, file navigation, and command output display
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0025-tui-package-reorganization.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0025. TUI Package Reorganization (Interface-Driven Design)

**Status:** Proposed
**Status:** Implemented
**Date:** 2024-06-17
**Deciders:** Developer, Architect
**Technical Story:** Refactoring the monolithic `pkg/executor/tui` package into maintainable subpackages.
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0033-notes-viewer-tui-command.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0033. Notes Viewer TUI Command

**Status:** Proposed
**Status:** Implemented
**Date:** 2024-12-19
**Deciders:** Engineering Team
**Technical Story:** Implementation of `/notes` slash command to view agent scratchpad notes in TUI
Expand Down
157 changes: 95 additions & 62 deletions docs/adr/0051-tui-visual-redesign.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 51. TUI Visual Redesign

**Status:** Proposed
**Status:** Implemented
**Date:** 2025-01-26
**Deciders:** Product Team, Engineering Team
**Technical Story:** [TUI Visual Polish PRD](../product/features/tui-visual-polish.md) — compact header and input box design
Expand Down Expand Up @@ -126,7 +126,7 @@ Option 2 delivers the full visual improvement with a contained, reviewable diff.

### Neutral

- `headerHeight` changes from `10` to `3` — must be updated in one place (`update.go:319`)
- `headerHeight` changes from `10` to `4` — must be updated in one place (`calculateViewportHeight()` in `update.go`)
- `buildHeader()` function is replaced in-place; callers do not change

---
Expand All @@ -140,47 +140,65 @@ Replace `buildHeader()` in `pkg/executor/tui/view.go`:
```go
// buildHeader renders the compact single-line header bar.
// Total height: 2 lines (bar + separator).
func (m model) buildHeader() string {
// Truncate working directory to fit in available width
cwd := m.workingDir
func (m *model) buildHeader() string {
// Resolve model name from the LLM provider at render time.
modelName := ""
if m.provider != nil {
modelName = m.provider.GetModel()
}

// Truncate working directory to fit in available width.
// Uses m.workspaceDir — the field name on the model struct.
cwd := m.workspaceDir
if abs, err := filepath.Abs(cwd); err == nil {
cwd = abs
}
if m.width > 0 && lipgloss.Width(cwd) > m.width/2 {
cwd = "…" + cwd[len(cwd)-(m.width/2):]
}

left := m.styles.brandStyle.Render("⬡ forge")
mid := m.styles.mutedStyle.Render(cwd)
right := m.styles.mutedStyle.Render(m.modelName + " " + version.Version)
// Package-level style vars — no m.styles struct exists.
left := headerStyle.Render("⬡ forge")
mid := tipsStyle.Render(cwd)
right := tipsStyle.Render(modelName + " v" + version.Version)

// Fill space between left, mid, right
// Fill space between left, mid, right.
totalUsed := lipgloss.Width(left) + lipgloss.Width(mid) + lipgloss.Width(right)
gap := (m.width - totalUsed) / 2
if gap < 1 { gap = 1 }
pad := strings.Repeat(" ", gap)

bar := left + pad + mid + pad + right
separator := m.styles.dimStyle.Render(strings.Repeat("─", m.width))
separator := inputRuleStyle.Render(strings.Repeat("─", m.width))
return bar + "\n" + separator
}
```

**`pkg/executor/tui/update.go:319`** — update `headerHeight`:
**`pkg/executor/tui/update.go`** — update `headerHeight` in `calculateViewportHeight()`:

```go
// headerHeight is the number of lines occupied by buildHeader() output.
// Compact header: 1 line bar + 1 line separator = 2 lines.
// Plus 1 line for the contextual hints = 3 total.
headerHeight := 3
// headerHeight is the number of lines occupied by chrome above the viewport.
// Breakdown (see assembleBaseView):
// header = buildHeader() = 2 lines (bar + separator)
// tips = buildTips() = 1 line
// spacer = blank "" = 1 line
// Total = 4. Static — never grows.
const headerHeight = 4
```

### Step 2 — Option B input box (styles.go + view.go, ~20 lines)

**`pkg/executor/tui/styles.go`** — replace `inputBoxStyle` with `inputRuleStyle`:
**`pkg/executor/tui/styles.go`** — add `dimSep` color and `inputRuleStyle`:

```go
// inputRuleStyle renders the horizontal rule above the input field (Option B).
// Color additions
dimSep = lipgloss.Color("#374151") // dim separator — slightly lighter than bg, used for rules/dividers

// inputRuleStyle renders the horizontal rule above the input field and
// the header separator (Option B design, ADR-0051 Steps 2 & 5).
// Uses dimSep (#374151) — darker than mutedGray so the rule recedes visually.
inputRuleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color(mutedGray)).
Width(0) // width set dynamically in buildInputBox
Foreground(dimSep)

// inputPromptStyle styles the ❯ glyph
inputPromptStyle = lipgloss.NewStyle().
Expand Down Expand Up @@ -225,46 +243,53 @@ height on every recalculation — not a fixed number.

```go
func (m *model) calculateViewportHeight() int {
// headerHeight: bar + separator + hints line. Static — never grows.
const headerHeight = 3

// inputZoneHeight is dynamic: rule (1) + live textarea lines + hints (1).
// Use live line count from the textarea value; cap at height/3 so a very
// long draft cannot reduce the viewport to zero.
// headerHeight: bar (1) + separator (1) + tips (1) + blank spacer (1) = 4 lines.
// These are the rows prepended by assembleBaseView before the viewport.
// Static — never grows with content.
const headerHeight = 4

// inputZoneHeight is dynamic: rule (1) + live textarea lines.
// Tips are already counted in headerHeight (rendered by buildTips() above).
// Use strings.Count on the actual textarea value so the height is in sync
// with content in the same tick — avoids the one-frame lag from textarea.Height()
// which reflects the allocated component height, not live content lines.
liveLines := strings.Count(m.textarea.Value(), "\n") + 1
if liveLines < 1 {
liveLines = 1
}
maxInputLines := m.height / 3
if maxInputLines < 1 {
maxInputLines = 1
}
if liveLines > maxInputLines {
liveLines = maxInputLines
}
inputZoneHeight := 1 + liveLines + 1 // rule + textarea + hints
inputZoneHeight := 1 + liveLines // rule + live content lines

const statusBarHeight = 1

available := m.height - headerHeight - inputZoneHeight - statusBarHeight
if available < 1 {
available = 1
// Reserve one line for the loading spinner when the agent is busy.
loadingHeight := 0
if m.agentBusy {
loadingHeight = 1
}
return available
// ADR-0048: reserve one line for the scroll-lock "new content" indicator.
scrollIndicatorHeight := 0
if !m.followScroll && m.hasNewContent {
scrollIndicatorHeight = 1
}

viewportHeight := m.height - headerHeight - inputZoneHeight - statusBarHeight - loadingHeight - scrollIndicatorHeight
if viewportHeight < 5 {
viewportHeight = 5
}
return viewportHeight
}
```

**Resize trigger**: call `m.recalculateLayout()` whenever the textarea content line count
changes. In the textarea update branch of `Update()`:

```go
prevLines := strings.Count(m.textarea.Value(), "\n")
newTextarea, cmd := m.textarea.Update(msg)
m.textarea = newTextarea
if strings.Count(m.textarea.Value(), "\n") != prevLines {
// Compare live line count before/after update so calculateViewportHeight()
// always reads the latest content (ADR-0051 §2b).
oldLines := strings.Count(m.textarea.Value(), "\n") + 1
m.textarea, tiCmd = m.textarea.Update(msg)
newLines := strings.Count(m.textarea.Value(), "\n") + 1

if oldLines != newLines && m.ready {
m.recalculateLayout()
}
return m, cmd
```

This ensures the viewport shrinks when the user types a multiline draft and expands back when they
Expand Down Expand Up @@ -295,26 +320,31 @@ Replace the static `buildTips()` function with a state-aware version:

```go
// buildTips returns a single-line hints string adapted to the current TUI state.
func (m model) buildTips() string {
muted := m.styles.mutedStyle

// Note: uses package-level tipsStyle (no m.styles struct); checks m.overlay.isActive()
// not m.overlay != nil (overlayState always exists, may be inactive); uses m.agentBusy
// not m.agentRunning; includes a bashMode case for the bash REPL sub-mode.
func (m *model) buildTips() string {
switch {
case m.overlay != nil:
// An overlay is open — show overlay-specific hints
return muted.Render(" Esc · close Tab · next field Enter · confirm")
case m.overlay.isActive():
// An overlay is open — show overlay-specific hints.
return tipsStyle.Render(" Esc · close Tab · next field Enter · confirm")

case m.agentRunning:
// Agent is active
case m.agentBusy:
// Agent is active — show interrupt hint.
hints := " Ctrl+C · interrupt"
if !m.followScroll {
hints += " G · follow output"
}
return muted.Render(hints)
return tipsStyle.Render(hints)

case m.bashMode:
// Bash REPL sub-mode — show bash-specific hints.
return tipsStyle.Render(" Enter · run exit · return to normal Ctrl+C · cancel")

default:
// Idle
return muted.Render(
" Enter · send Alt+Enter · new line / · commands Ctrl+Y · copy Ctrl+C",
// Idle — show full send/command hints.
return tipsStyle.Render(
" Enter · send Alt+Enter · new line / · commands Ctrl+Y · copy Ctrl+C · exit",
)
}
}
Expand All @@ -325,15 +355,18 @@ func (m model) buildTips() string {
Add any missing style constants used above:

```go
const (
var (
// existing
salmonPink = "#FFB3BA"
mutedGray = "#6B7280"
// new
dimSeparator = "#374151" // slightly lighter than background for rule/separator
salmonPink = lipgloss.Color("#FFB3BA")
mutedGray = lipgloss.Color("#6B7280")
// new — dim separator, slightly lighter than background for rules and dividers
dimSep = lipgloss.Color("#374151")
)
```

> **Note:** The constant in code is named `dimSep` (not `dimSeparator`). All color vars are
> `lipgloss.Color` values (not raw strings), declared in a `var` block in `styles.go`.

### Migration Path

`pkg/ui/ascii.go` is retained. `GenerateASCIIArt()` is no longer called from `view.go:buildHeader()` but is not deleted — it may be used in other contexts (CLI startup banner, help text). Remove the call from `buildHeader()`; do not delete the function.
Expand Down
Loading