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
90 changes: 79 additions & 11 deletions docs/coding-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -757,7 +757,9 @@ IDs, grouped by family with the light variant first:
- `catppuccin-latte`, `catppuccin-frappe`, `catppuccin-macchiato`,
`catppuccin-mocha` — the Catppuccin flavours, light to darkest;
- `dracula`;
- `everforest-light`, `everforest-dark`;
- `gruvbox-light`, `gruvbox-dark`;
- `kanagawa`;
- `modus-operandi`, `modus-vivendi` — the Modus pair, built for WCAG AAA
contrast;
- `nord`;
Expand All @@ -766,10 +768,38 @@ IDs, grouped by family with the light variant first:
- `solarized-light`, `solarized-dark`;
- `tokyo-night-light`, `tokyo-night-storm`, `tokyo-night`.

Every built-in highlights its fenced code with the syntax style that belongs to
the same family, so the UI accents and the code block agree: `tokyo-night`
borrows Tokyo Night's `tokyonight-night` style, `gruvbox-dark` borrows Gruvbox's,
and a palette from a file follows the family its background resolves to.
A built-in whose family ships a syntax style of its own highlights fenced code
with that style, so the UI accents and the code block agree: `tokyo-night`
borrows Tokyo Night's `tokyonight-night` style and `gruvbox-dark` borrows
Gruvbox's.

A family chroma ships no style for — Everforest, Kanagawa and the others — and
every palette loaded from a file get a complete style derived from their own 14
palette roles instead, so a fence reads as part of its theme rather than as a
borrowed one. The derivation follows the palette's meaning rather than any one
family's syntax theme:

- `workspace` is plain code text, names and identifiers;
- `muted` is everything to skim past: comments, punctuation, string delimiters
and subordinate headings;
- `model` is the primary accent (the one headings use): keywords, types, markup;
- `session` is the secondary accent (links and the cursor use it): calls,
builtins, prompts;
- `change` is the value accent: strings, added diff lines;
- `active` is the attention accent: numbers and constants;
- `warning` is the caution accent: escapes, decorators, entities;
- `error` is reserved for failures: exceptions, lexer errors, removed diff
lines.

`separator`, `composer_prompt`, `code_background` and `diagnostic` are chrome,
surfaces and the status panel's own channel, so a fence never draws with them.
The full role-to-token table lives in `markdown_palette_style.go`.

A fence paints no fill, so its tokens are read against your terminal's own
background rather than against the theme's `code_background`; the accents are
tuned the way each family tunes them, and the guard checks the two pairings that
do share `code_background` — the fence body and the inline-code pill — at 4.0:1 or
better for every built-in.

Open the command picker with `/` and choose `/theme`. The picker lists `auto`,
built-ins, and discovered user themes in stable order. Press Enter to apply a
Expand All @@ -796,8 +826,8 @@ Markdown draws on the terminal's own canvas. No heading paints a background, a
code fence paints no block background, and the inline-code fill is painted only
when the theme's own background is the one the terminal reported, so a light
theme inside a dark terminal drops the fill instead of drawing a bright pill.
A fence keeps only the token colors of its bundled syntax palette; a `diff`
fence therefore still shows its added and removed fills.
A fence never paints a token fill: a `diff` fence's added and removed lines
carry the theme's own `change` and `error` colors on the terminal's canvas.

All TUI selections are stored together in the active configuration file under
`[tui]`:
Expand Down Expand Up @@ -831,7 +861,13 @@ window, or no reported prompt size — and a provider that reports no cache read
shows an honest `0% cache`. `model` renders a short display name instead of
`provider/model`: the provider prefix and any nested catalog path are dropped, so
`xai/grok-4.5` reads as `Grok 4.5` and `cline-pass/deepseek-v4.1-flash` as
`DeepSeek V4.1 Flash`. The other fields are `workspace`, `session`,
`DeepSeek V4.1 Flash`. The name is followed by the reasoning level the next
request will carry, in parentheses — `Claude Opus 4.1 (high)`. That level comes
from the resolved model, so a configured `default` shows the level the model's own
metadata resolves it to; a model that declares reasoning levels but has none
selected shows `(default)`, which is the honest answer because the provider's own
default is unknown to Pips; a model with no reasoning levels shows no suffix at
all. The other fields are `workspace`, `session`,
`task_progress` (`Tasks completed/total`), `phase`, `mode` (Plan mode), and
`team`. `/statusline` toggles and reorders them.

Expand Down Expand Up @@ -962,10 +998,13 @@ left click can move the selection on a full-area list (`/resume`, `/skills`,
`/mcp`, `/tree`, `/agents` and its Runs/Library tabs) — a click selects a row and
activates nothing, and motion events are ignored, so the pointer never repaints a
frame by hovering. Text is selected in the viewport itself: drag over the
conversation and the addressed rows
are copied through the same clipboard path as `Ctrl+X` (an OSC 52 request plus a
fallback file) with a short confirmation on the status line, and `Esc` clears the
highlight. Rows are copied as they are painted, so a paragraph that wrapped is
conversation and the addressed rows go to the clipboard through an OSC 52 request
only — a drag is a transient reading gesture, so it never writes the fallback
file that `Ctrl+X`, `/copy` and `/export` use — and the reserved band under the
transcript confirms it with `copied 3 lines`. A confirmed copy clears the
highlight; a copy that could not be made keeps the selection and reports why.
`Esc` clears the selection too. Rows are copied as they are painted, so a
paragraph that wrapped is
copied as the lines on screen; `/copy` and `/export` give the reflow-free text.
With no route open, `Ctrl+R` releases the capture for the session (and takes it
back), which hands selection, copy and link handling to the terminal; while the
Expand All @@ -979,6 +1018,35 @@ otherwise). Neither
mode ever clears the terminal's scrollback, and both restore terminal state before
the session is released.

Between the activity line and the composer box the frame reserves one blank row:
the reserved band. It stays blank while there is nothing to say, and it carries
the immediate feedback that is not the status line's job. While the transcript is
not at the bottom — after PgUp/PgDn, Home/End or a wheel notch — it shows a
centred `▼`, and a left click anywhere on that row returns to the newest rows
exactly as `End` does; the status line no longer repeats that hint. The glyph
carries no label: the band is a signpost, and the status line is where keys are
named. When a drag copy is confirmed, the band reports `copied 3 lines` for a few
seconds, on the same clock as a status notice; with both present the confirmation
keeps the right edge and the signpost is centred to its left, and the
confirmation wins the row when the two cannot both be shown. The band is one row
on every frame, so the composer never moves when its contents change.

The band's up counterpart is the turn indicator. While anything is above the
window, the transcript's first row carries a centred `▲`; a left click on that
one cell puts the newest prompt above the window at its top, so repeated clicks
walk back through the conversation one turn at a time — that is why the arrow
stays on screen after each jump, and why it only leaves once the window reaches
the beginning of the conversation. The two arrows bracket the reader: `▲` walks
back a turn, `▼` returns to the newest rows. Both are the filled triangles on
purpose: the hollow triangle is this UI's
attention marker (an awaiting approval, a compacting context, the theme picker's
polarity mark), so the scroll pair reads as movement instead of as a warning. The
indicator is drawn into the first row rather than into a band of its
own, because there is no free row above the transcript — reserving one would take
a row from the messages and would change the window height that decides whether
the indicator is needed at all, so showing it could hide it again on the next
frame. It costs no message row and disappears as soon as your prompt is on screen.

`show_thinking_blocks = true` (the default) renders the model's visible reasoning
as a Thinking block in the fullscreen viewport: the whole thought is shown under
a `Thinking` label, wrapped and dimmed in the active theme's muted color, and it
Expand Down
5 changes: 3 additions & 2 deletions internal/coding/cli/reasoning_report.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ func reasoningEncoding(resolved modelcatalog.ResolvedModel) (string, bool) {
// reasoningSelection returns the level that will be sent, or "" when the
// selection falls back to the provider default.
func reasoningSelection(resolved modelcatalog.ResolvedModel) string {
if resolved.ReasoningLevel == nil {
level, ok := resolved.EffectiveReasoningLevel()
if !ok {
return ""
}

return string(*resolved.ReasoningLevel)
return string(level)
}

// writeResolvedReasoning prints the reasoning state that will actually reach
Expand Down
17 changes: 17 additions & 0 deletions internal/coding/modelcatalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,23 @@ func (m ResolvedModel) Clone() ResolvedModel {
return cloned
}

// EffectiveReasoningLevel reports the reasoning level the request assembly sends
// for this model, and whether one is selected at all.
//
// [registry.resolve] folds the explicit selection, the variant's level and the
// model's DefaultReasoningLevel into ReasoningLevel, and the generation layer
// sends exactly that value. A false result means pips sends no level and the
// provider's own default applies, which pips cannot know: it ships no model
// capability database. A caller must therefore show that case as "default" or
// omit it rather than inventing a level.
func (m ResolvedModel) EffectiveReasoningLevel() (config.ReasoningLevel, bool) {
if m.ReasoningLevel == nil || *m.ReasoningLevel == "" {
return "", false
}

return *m.ReasoningLevel, true
}

// Equal reports value equality without relying on pointer or map identity.
func (m ResolvedModel) Equal(other ResolvedModel) bool {
return reflect.DeepEqual(m, other)
Expand Down
155 changes: 155 additions & 0 deletions internal/coding/tui/band.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
//nolint:wsl_v5 // The band's two occupants and their alignment stay adjacent.
package tui

import (
"fmt"
"strings"
"time"

tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)

// The reserved band is the row between the activity line and the Composer. The
// frame budget counts it on every frame (layoutBandRows), so the Composer never
// moves when the band's contents change: the band is one fixed row that is blank
// until there is something to say.
//
// Two things live there today.
//
// - A scroll hint, drawn only while the newest transcript rows are off screen.
// It is the affordance that replaces the status line's scroll hint, and a left
// click anywhere on the band row restores the newest rows, the way End does.
// - The confirmation of a drag copy ("copied 3 lines"). The drag path writes
// the clipboard alone, so it has no file to name; an explicit /copy, /export
// or path copy keeps its status-line notice, which does name the file. The
// two surfaces therefore never repeat each other.
const (
// bandScrollIcon marks the direction the newest rows are in. It is the filled
// triangle rather than the hollow one, which this UI uses as its attention marker;
// its up counterpart is the turn indicator at the top of the transcript. The glyph
// carries no label: the band is a signpost, and the status line already names the
// keys a reader needs.
bandScrollIcon = "▼"
)

// copiedNoticeExpiredMsg clears the band's copy confirmation only if it is still
// the current one, so a stale timer cannot erase a newer confirmation.
type copiedNoticeExpiredMsg struct {
generation uint64
}

// bandScrollHint is the marker the band shows while the newest output is off screen,
// or "" while the reader is already looking at it.
//
// The test is the scroll region's own "at the bottom" rather than its `follow`
// flag: a resize that grows the window clamps the offset onto the new maximum
// without restoring `follow`, and a hint driven by the flag would then point at
// rows that are already on screen.
func (m *Model) bandScrollHint() string {
if m.transcriptScroll.atBottom() {
return ""
}

return bandScrollIcon
}

// copiedNoticeText is the band's copy confirmation, or "" when none is current.
func (m *Model) copiedNoticeText() string {
return strings.TrimSpace(sanitizeInspectionText(m.copiedNotice))
}

// reservedBand renders the band's single row. The scroll hint is centred on the
// whole row, because it is a standing invitation to move rather than a field of
// the status line; the copy confirmation keeps the status line's inset and its
// right edge, which is where a transient outcome reads as an outcome.
//
// A narrow terminal truncates the hint's label rather than the outcome, and
// NO_COLOR drops the styling and keeps the text.
//
// The row is blank when there is nothing to show, which is what keeps a frame
// with no live feedback byte-identical to the blank separator row it replaced.
func (m *Model) reservedBand() string {
hint := m.bandScrollHint()
notice := m.copiedNoticeText()
if hint == "" && notice == "" {
return ""
}
if !m.options.NoColor {
muted := lipgloss.NewStyle().Foreground(paletteFor(m.theme).muted)
hint = styleWhenNotEmpty(muted, hint)
notice = styleWhenNotEmpty(muted, notice)
}

width := max(1, m.width)
inset := timelineInset(width)

switch {
case hint == "":
// A copy confirmation keeps the status line's inset and right edge, where a
// transient outcome reads as an outcome.
return strings.Repeat(" ", inset) + alignStatusLine("", notice, width-2*inset)
case notice == "":
// The scroll hint is centred on the whole row: it is a standing invitation to
// move rather than a field of the status line.
return centreLine(hint, width)
}

// Both. The row is [hint][gap][confirmation][inset]; the confirmation wins it
// when the hint could not keep its glyphs.
noticeField := ansi.StringWidth(notice)
hintField := width - 1 - noticeField - inset
if hintField < ansi.StringWidth(bandScrollIcon) {
return strings.Repeat(" ", inset) + alignStatusLine("", notice, width-2*inset)
}

return centreLine(hint, hintField) + " " + notice + strings.Repeat(" ", inset)
}

// centreLine centres value in a field of width columns. The row it returns always
// measures exactly width, so the band stays aligned with the frame edge whatever
// it holds, and an odd remainder leaves the extra column on the right.
func centreLine(value string, width int) string {
if width <= 0 {
return ""
}

value = ansi.Truncate(value, width, "…")
padding := width - ansi.StringWidth(value)
left := padding / 2

return strings.Repeat(" ", left) + value + strings.Repeat(" ", padding-left)
}

func styleWhenNotEmpty(style lipgloss.Style, value string) string {
if value == "" {
return ""
}

return style.Render(value)
}

// copiedLinesLabel words how much text a copy took, so the reader can tell a
// one-line pick from a screenful without pasting.
func copiedLinesLabel(lines int) string {
if lines == 1 {
return "copied 1 line"
}

return fmt.Sprintf("copied %d lines", lines)
}

// showCopiedNotice puts a copy confirmation in the band for as long as a status
// notice lives, so both surfaces expire on the same clock.
func (m *Model) showCopiedNotice(lines int) tea.Cmd {
m.copiedNoticeSeq++
m.copiedNotice = copiedLinesLabel(lines)
m.setLayout()

generation := m.copiedNoticeSeq

return tea.Tick(statusNoticeTime, func(time.Time) tea.Msg {
return copiedNoticeExpiredMsg{generation: generation}
})
}
Loading
Loading