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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

### Added

- `/code <path>[:line]`: full-screen source viewer with syntax highlighting, a
CJK-aware caret (`j`/`k`, `g`/`G`, `h`/`l`), and `v` to select lines then `a`
to hand them to the chat input. The pane reads files itself, so it answers to
the same deny list as the tool gate.

### Changed

### Deprecated
Expand All @@ -18,6 +23,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

### Fixed

- Horizontal scrolling no longer drops characters from the middle of a
wide-glyph (CJK) line in the diff and file views.

### Security

## [0.27.4] - 2026-09-20
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ A lean, high-performance terminal coding agent harness in Go — a sibling to Pi
- [Configuration](#configuration)
- [Interactive mode](#interactive-mode)
- [Diff review](#diff-review)
- [Code viewer](#code-viewer)
- [Commands](#commands)
- [Sessions](#sessions)
- [Headless mode](#headless-mode)
Expand Down Expand Up @@ -255,7 +256,7 @@ syntax highlighting. Structural markers (`#`, `` ` ``, `*`) are stripped.
The editor supports:

- `@` — fuzzy file mention picker (type `@` and start typing a path)
- `/` — slash command picker (`/sessions`, `/branch`, `/clear`, `/diff`)
- `/` — slash command picker (`/sessions`, `/branch`, `/clear`, `/diff`, `/code`)
- `?` — shortcut help picker (lists `/`, `!`, `@`, and key bindings; `Esc` closes)
- `!command` — run a shell command locally and stream its output into the
transcript (see [Commands](#commands))
Expand Down Expand Up @@ -291,6 +292,18 @@ Slash-picker Enter inserts `/diff` plus a trailing space into the composer; subm
`s` side-by-side, `i` add/edit a note, `x` delete, `a` send notes to the agent,
`?` help, `q` / `Esc` close. Notes persist under `.phi/review.json`.

## Code viewer

`/code <path>[:line]` opens a full-screen source viewer: syntax highlighting, a
CJK-aware caret (`j`/`k`, `h`/`l`, `gg`/`G`), and `v` to select lines then `a`
to hand them to the chat input as a `path:12-18` reference the model reads.
The pane reads files itself and answers to the same deny list as the tool
gate, so sensitive paths (`~/.ssh`, `.env`, …), binaries and files over 8 MiB
are refused with a toast.

`Esc` closes. The status row shows the path, caret and line count while
reading, and the selection size while `v` is active.

## Branch switching

`/branch` opens the branch picker for the working directory. Two columns: the
Expand Down Expand Up @@ -324,6 +337,7 @@ unfinished.
| `/branch` | Switch the working branch — see [Branch switching](#branch-switching) |
| `/clear` | Start a fresh empty session (TUI) |
| `/diff` | Full-screen git review — see [Diff review](#diff-review) |
| `/code` | Full-screen source viewer — see [Code viewer](#code-viewer) |
| `!command` | Run a shell command locally, stream output into the transcript; `Esc` cancels it |

In the TUI, `!command` runs locally via `bash -c` — outside the agent loop. It
Expand Down
14 changes: 13 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
- [配置](#配置)
- [交互模式](#交互模式)
- [Diff 审阅](#diff-审阅)
- [代码查看器](#代码查看器)
- [命令](#命令)
- [会话](#会话)
- [无头模式](#无头模式)
Expand Down Expand Up @@ -249,7 +250,7 @@ xychart-beta
编辑器支持:

- `@` —— 模糊文件选择器(输入 `@` 后开始输入路径)
- `/` —— 斜杠命令选择器(`/sessions`、`/branch`、`/clear`、`/diff`)
- `/` —— 斜杠命令选择器(`/sessions`、`/branch`、`/clear`、`/diff`、`/code`)
- `?` —— 快捷键帮助选择器(列出 `/`、`!`、`@` 和按键绑定;`Esc` 关闭)
- `!command` —— 在本地运行 shell 命令,并把输出流式写入对话记录
(见 [命令](#命令))
Expand Down Expand Up @@ -285,6 +286,16 @@ xychart-beta
审阅层内:`j`/`k` 移动,`s` 左右对照,`i` 添加/编辑批注,`x` 删除,`a` 发给代理,
`?` 帮助,`q` / `Esc` 关闭。批注保存在 `.phi/review.json`。

## 代码查看器

`/code <path>[:line]` 打开全屏源码查看器:语法高亮、支持 CJK 宽度的光标
(`j`/`k`、`h`/`l`、`gg`/`G`),`v` 选中行后按 `a`,把选中的行作为
`path:12-18` 引用交给聊天输入框,模型直接可读。查看器自己读文件,并遵守
与工具门相同的敏感路径清单——`~/.ssh`、`.env` 等敏感路径、二进制文件和
超过 8 MiB 的文件都会拒绝并以 toast 提示。

`Esc` 关闭。阅读时状态栏显示路径、光标和行数;`v` 激活时显示已选中行数。

## 切换分支

`/branch` 打开当前目录的分支选择器,只有两列:分支名和它最近的一次提交。
Expand Down Expand Up @@ -314,6 +325,7 @@ HEAD 新建一个。敲名字本身就是「新建分支」的全部流程,比
| `/branch` | 切换工作分支 — 见 [切换分支](#切换分支) |
| `/clear` | 开启一个全新的空会话(TUI 内) |
| `/diff` | 全屏 git 审阅 — 见 [Diff 审阅](#diff-审阅) |
| `/code` | 全屏源码查看器 — 见 [代码查看器](#代码查看器) |
| `!command` | 在本地运行 shell 命令,把输出流式写入对话记录;`Esc` 取消 |

在 TUI 中,`!command` 通过 `bash -c` 在本地运行——在代理循环之外。它不计入
Expand Down
3 changes: 2 additions & 1 deletion doc/project-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
| `internal/util/update/` | Self-update check + GitHub Releases install |
| `internal/agent/` | Agent engine, executor, jobs |
| `internal/agent/prompt/` | System prompt templates + Skills/MCP catalogs |
| `internal/components/` | TUI widgets (chat, input, palette, mention, diffview, …) |
| `internal/components/` | TUI widgets (chat, input, palette, mention, diffview, codeview, …) |
| `internal/llm/` | LLM clients (OpenAI-compatible + Anthropic + Gemini), streaming, skills |
| `internal/project/` | Workspace layout and config |
| `internal/project/model/` | Built-in model presets + request interceptors |
Expand All @@ -21,6 +21,7 @@
| `internal/tui/footer/` | Activity spinner, token labels, update hint |
| `internal/tui/overlays/` | Permission / continue-ask panels |
| `internal/tui/diffpane/` | Git diff review overlay (`/diff`) |
| `internal/tui/codepane/` | Source viewer overlay (`/code`) |
| `internal/tui/submit/` | Submit, cancel, slash dispatch, bash runner |
| `internal/tui/commands/` | Slash/palette registry, session/extension commands |
| `internal/tui/pathutil/` | Cwd + git branch path labels |
Expand Down
19 changes: 14 additions & 5 deletions doc/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ cmd/main.go
├─ FooterChrome status slot (activity↔tokens), bottom row for ext/jobs/hints
├─ Overlays permission ask, continue ask
├─ DiffPane full-screen git diff review (`/diff`)
├─ CodePane full-screen source viewer (`/code`)
└─ Submitter submit / cancel / slash / bash → Controller
```

Expand All @@ -25,6 +26,7 @@ cmd/main.go
| `FooterChrome` | `ActivityHandler`, `Spinner` | `labelContext()`, `liveJobs()` closures |
| `Overlays` | `permAskState`, `continueAskState` | `activity` ref, reply callbacks |
| `DiffPane` | review overlay (rows, notes, search) | `cwd`, submit/copy/toast callbacks |
| `CodePane` | source overlay (lines, caret, selection) | `cwd`, add-to-chat/toast callbacks |
| `Submitter` | `BashRunner` | `Controller`, `Bus`, `CommandRegistry`, pane refs |

**Hard rule:** no `*Editor` back-pointers on handlers. Cross-domain work uses injected refs, callbacks, or `Bus.Publish`. Toast feedback uses `ToastMsg` (Editor owns the overlay); do not inject toast callbacks.
Expand All @@ -42,6 +44,7 @@ internal/tui/
├── footer/ # FooterChrome, token label helpers
├── overlays/ # permission + continue ask
├── diffpane/ # git diff review overlay (`/diff`)
├── codepane/ # source viewer overlay (`/code`)
├── submit/ # Submitter, BashRunner
├── commands/ # registry, builtins, SessionCommands, BranchCommands, ExtCommands
└── pathutil/ # short path + git branch labels
Expand All @@ -56,6 +59,7 @@ internal/tui/
| `footer` | Composer status slot (activity ↔ tokens), bottom footer row (ext status, jobs, update hint) |
| `overlays` | Modal permission / continue-ask panels; replaces composer when active |
| `diffpane` | Full-screen git diff review; comments persist under `.phi/review.json` |
| `codepane` | Full-screen source viewer: syntax highlight, caret, line selection |
| `submit` | User submit path: agent prompt, slash commands, `!bash`, cancel |
| `commands` | Slash/palette registry; session load/clear; extension command bridge |
| `pathutil` | Cwd shortening and git branch labels for composer chrome |
Expand Down Expand Up @@ -96,18 +100,24 @@ It does **not** own command side effects — those live in `internal/tui/command

```text
xui event
└─ Editor.Handle → ComposerPane.Handle (keys, paste, focus)
├─ overlay keys → Overlays (when active)
├─ copy keys → TranscriptPane
└─ submit → bus.Publish(SubmitMsg)
└─ Editor.Handle → full-screen overlay (DiffPane / CodePane, when active)
└─ ComposerPane.Handle (keys, paste, focus)
├─ overlay keys → Overlays (when active)
├─ copy keys → TranscriptPane
└─ submit → bus.Publish(SubmitMsg)

app frame
└─ Editor.Draw
├─ drainBus() # apply pending Msg batch on UI thread
├─ full-screen overlay (DiffPane / CodePane) + toast
├─ layout: list | chat/overlay | footer
└─ toast overlay (if visible)
```

Both full-screen overlays (`DiffPane`, `CodePane`) are UI-goroutine objects: they
load, handle keys and paint in one place, and their callbacks (add-to-chat, copy,
toast) run inline — no pane state needs a lock.

`RequestRedraw` → `vx.QueueRefresh()`. The bus coalesces high-frequency stream events; one armed wake can cover many publishes until the next `Drain`.

---
Expand Down Expand Up @@ -135,7 +145,6 @@ app frame
| `MentionResultsMsg`, `BranchLabelMsg` | `ComposerPane` |
| `ToastMsg` | `Editor` toast overlay |
| `ExtCommandResultMsg` | `ExtCommands` |
| `RedrawMsg` | no-op (redraw already scheduled) |

---

Expand Down
84 changes: 83 additions & 1 deletion internal/components/chat/chat_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ type ChatInput struct {
// "Images: name1 name2".
PendingImages []imgutil.Attachment

// PendingRefs are file slices picked in the code viewer, shown inside the
// bordered editor as "Refs: path:12-18".
PendingRefs []Ref

PaddingX int // horizontal inner padding; default 1

// OnSubmit is called when Enter is pressed (without modifiers).
Expand All @@ -70,6 +74,8 @@ type ChatInput struct {
OnPendingSkillsChange func(skills []string)
// OnPendingImagesChange is called after PendingImages mutates.
OnPendingImagesChange func(images []imgutil.Attachment)
// OnPendingRefsChange is called after PendingRefs mutates.
OnPendingRefsChange func(refs []Ref)

// OnMentionChange is called after Value or Cursor changes that may
// activate/deactivate an @-file mention. active is false when none.
Expand Down Expand Up @@ -128,6 +134,9 @@ func (c *ChatInput) PreferredHeight(width int, method xui.WidthMethod) int {

func (c *ChatInput) pendingRowsHeight() int {
n := 0
if len(c.PendingRefs) > 0 {
n++
}
if len(c.PendingSkills) > 0 {
n++
}
Expand Down Expand Up @@ -162,6 +171,21 @@ func (c *ChatInput) AddPendingImage(att imgutil.Attachment) {
c.notifyPendingImages()
}

// AddPendingRef appends r to the pending references. A ref with no path or no
// selected text carries nothing, so it is dropped.
func (c *ChatInput) AddPendingRef(r Ref) {
if r.Path == "" || r.Text == "" {
return
}
if slices.ContainsFunc(c.PendingRefs, func(p Ref) bool {
return p.Path == r.Path && p.Start == r.Start && p.End == r.End
}) {
return
}
c.PendingRefs = append(c.PendingRefs, r)
c.notifyPendingRefs()
}

// PopPendingImage removes the last pending image. Returns false if none.
func (c *ChatInput) PopPendingImage() bool {
if len(c.PendingImages) == 0 {
Expand All @@ -172,6 +196,16 @@ func (c *ChatInput) PopPendingImage() bool {
return true
}

// PopPendingRef removes the last pending reference. Returns false if none.
func (c *ChatInput) PopPendingRef() bool {
if len(c.PendingRefs) == 0 {
return false
}
c.PendingRefs = c.PendingRefs[:len(c.PendingRefs)-1]
c.notifyPendingRefs()
return true
}

// PopPendingSkill removes the last pending skill. Returns false if none.
func (c *ChatInput) PopPendingSkill() bool {
if len(c.PendingSkills) == 0 {
Expand Down Expand Up @@ -200,6 +234,15 @@ func (c *ChatInput) ClearPendingSkills() {
c.notifyPendingSkills()
}

// ClearPendingRefs removes all pending references.
func (c *ChatInput) ClearPendingRefs() {
if len(c.PendingRefs) == 0 {
return
}
c.PendingRefs = nil
c.notifyPendingRefs()
}

func (c *ChatInput) notifyPendingSkills() {
if c.OnPendingSkillsChange != nil {
c.OnPendingSkillsChange(c.PendingSkills)
Expand All @@ -212,6 +255,12 @@ func (c *ChatInput) notifyPendingImages() {
}
}

func (c *ChatInput) notifyPendingRefs() {
if c.OnPendingRefsChange != nil {
c.OnPendingRefsChange(c.PendingRefs)
}
}

func (c *ChatInput) padX() int {
if c.PaddingX <= 0 {
return 1
Expand Down Expand Up @@ -269,6 +318,8 @@ func (c *ChatInput) Handle(ctx *components.EventContext, ev xui.Event) {
debuglog.Logf("chat backspace popped pending image remaining=%d", len(c.PendingImages))
} else if c.PopPendingSkill() {
debuglog.Logf("chat backspace popped pending skill remaining=%d", len(c.PendingSkills))
} else if c.PopPendingRef() {
debuglog.Logf("chat backspace popped pending ref remaining=%d", len(c.PendingRefs))
}
ctx.ConsumeAndRedraw()
return
Expand Down Expand Up @@ -440,14 +491,15 @@ func (c *ChatInput) notifyChange() {
c.notifyCompleters()
}

// clear removes all composer text, pending images, and pending skills.
// clear removes all composer text, pending images, refs, and skills.
func (c *ChatInput) clear() {
if c.Value != "" {
c.Value = ""
c.Cursor = 0
c.notifyChange()
}
c.ClearPendingImages()
c.ClearPendingRefs()
c.ClearPendingSkills()
}

Expand Down Expand Up @@ -588,6 +640,11 @@ func (c *ChatInput) Draw(ctx components.DrawContext) components.Surface {
}

contentY := 1
// Refs paint first so the pending rows stay contiguous above the editor.
if len(c.PendingRefs) > 0 {
c.paintPendingRefs(&s, 1+pad, contentY, innerW, ctx.Method)
contentY++
}
if len(c.PendingSkills) > 0 {
c.paintPendingSkills(&s, 1+pad, contentY, innerW, ctx.Method)
contentY++
Expand Down Expand Up @@ -689,6 +746,31 @@ func (c *ChatInput) Draw(ctx components.DrawContext) components.Surface {
return s
}

func (c *ChatInput) paintPendingRefs(s *components.Surface, x, y, width int, method xui.WidthMethod) {
th := c.Theme
if th.Success.Fg.Kind == 0 && th.Foreground.Fg.Kind == 0 {
th = components.DefaultTheme()
}
labelSt := th.Muted
labelSt.Dim = true
nameSt := th.IdentityOrSuccess()
nameSt.Bold = false
nameSt.Underline = true

spans := []components.Span{{Text: "Refs: ", Style: labelSt}}
for i, r := range c.PendingRefs {
if i > 0 {
spans = append(spans, components.Span{Text: " ", Style: labelSt})
}
spans = append(spans, components.Span{Text: r.Label(), Style: nameSt})
}
lines := components.WrapSpans(spans, width, method)
if len(lines) == 0 {
return
}
components.PaintSpans(s, x, y, lines[0], method)
}

func (c *ChatInput) paintPendingSkills(s *components.Surface, x, y, width int, method xui.WidthMethod) {
th := c.Theme
if th.Success.Fg.Kind == 0 && th.Foreground.Fg.Kind == 0 {
Expand Down
Loading
Loading