Skip to content
Draft
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
aa0f22e
docs(plan): native docs explorer
cameronsjo Aug 29, 2026
b60d52d
feat(docs): add native terminal reader
cameronsjo Aug 29, 2026
bb82b9d
docs: explain native terminal reader
cameronsjo Aug 29, 2026
a5798b8
fix(docs): preserve Kitty image payloads
cameronsjo Aug 29, 2026
696e47b
fix(docs): transmit Kitty image data
cameronsjo Aug 29, 2026
aecf08c
refactor(docs): reuse Kitty diacritics
cameronsjo Aug 29, 2026
81f5015
docs(plan): pivot docs reader to embedded preview
cameronsjo Aug 30, 2026
f350ca5
feat(docs): open preview inside cmux
cameronsjo Aug 30, 2026
44ccb4e
feat(docs): add reading appearance controls
cameronsjo Aug 30, 2026
57d380f
feat(docs): render referenced local images
cameronsjo Aug 30, 2026
8ee667b
docs: explain embedded docs preview
cameronsjo Aug 30, 2026
089cb15
fix(docs): preserve preview boundaries
cameronsjo Aug 30, 2026
9113693
fix(docs): satisfy preview safety checks
cameronsjo Aug 30, 2026
96a5214
docs(plan): record embedded preview verification
cameronsjo Aug 30, 2026
0889019
docs(plan): record PR update
cameronsjo Aug 30, 2026
b987028
docs(plan): refine docs preview for reading
cameronsjo Aug 30, 2026
4b26215
feat(docs): make embedded preview reading-first
cameronsjo Aug 30, 2026
0f2d659
fix(docs): keep preview chrome inline
cameronsjo Aug 30, 2026
bbf7e9c
docs(plan): record preview stopping point
cameronsjo Aug 30, 2026
0d87050
docs(plan): record draft PR handoff
cameronsjo Aug 30, 2026
c3a46ec
Merge origin/main into feat/docs-tui-reader (v0.15.0: docs reader v2)
cameronsjo Sep 1, 2026
5e33537
style(docs): gofmt assets.go after the merge union
cameronsjo Sep 1, 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: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ deep-dive get a link here.
| `branch` | Prune stale/orphaned git branches (alias: `br`) | Usage below |
| `clean` | Reclaim dep/build directories under a project root (alias: `cln`) | Usage below |
| `docker` | Build/run/shell images tagged from git repo/branch/sha | Usage below |
| `docs` | Local markdown reader: render + serve an indexed doc set over loopback HTTP | Usage below |
| `docs` | Native terminal docs explorer, with the loopback web reader retained as a fallback | Usage below |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
| `net` | Check cached reachability of the configured probe endpoint | Usage below |
| `proxy` | Apply config-defined profiles to the current shell through an explicit wrapper | [proxy](#proxy--current-shell-profiles) |
| `k8s` | Safely stream ordinary kubectl logs, plus bounded namespace/exec/inspect helpers | [k8s](#k8s--bounded-terminal-safe-log-streaming) |
Expand Down Expand Up @@ -201,9 +201,10 @@ forgectl docker build [context] -- --platform linux/arm64 # args after -- pass
forgectl docker run [-- args...] # run the built (or --tag) image
forgectl docker shell # open a shell in the built (or --tag) image

# docs — local markdown reader: render + serve an indexed doc set over loopback HTTP
# docs — rich HTML reader embedded in the current cmux workspace
forgectl docs [dir|file ...] # serve + open a right-hand cmux browser pane (cwd by default)
forgectl docs serve [dir|file ...] # render + serve, loopback-only (DNS-rebinding-safe)
forgectl docs serve --open # also open the system browser
forgectl docs serve --open # serve + open a separate system-browser tab
forgectl docs list [dir|file ...] # list the indexed docs, no server (--json for scripting)

# net — check cached reachability of the configured probe endpoint
Expand Down Expand Up @@ -300,6 +301,22 @@ forgectl y last 5 # print the 5 most recent zsh commands,
# acknowledgement only: forgectl does not scan or redact the history
```

Inside cmux, the ordinary `docs` command creates a browser pane on the right of
the invoking terminal without moving keyboard focus. The terminal owns the
foreground loopback server, so leave it running while you read and press Ctrl-C
there to close the server. Outside cmux, the same command opens the system
browser instead. Use `docs serve` when another process should own presentation,
or when you want remote or phone access through the existing address/token
options.

The reader renders sanitized Markdown, syntax highlighting, tables, Mermaid,
inline SVG, and relative local PNG, JPEG, GIF, WebP, AVIF, and SVG images. The
`Aa` control independently changes body, heading, and code fonts, text size,
line height, and line length; those choices persist in that browser. Relative
images are served only when an indexed document references them and still pass
the configured-root containment checks. Remote images remain blocked so opening
a local document does not notify a third party.

The cask doesn't stage an `fx` command — it's a shell alias you add yourself:

```sh
Expand Down
69 changes: 69 additions & 0 deletions docs/plans/2026-08-29-embedded-docs-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Embedded docs preview

## Goal

Explore whether `forgectl docs` becomes a genuinely pleasant reading tool when
its existing HTML reader is embedded in the caller's cmux workspace. Optimize
this slice for learning: remove the Kitty/TUI prototype, make the HTML path the
ordinary path, and address the reading gaps exposed by the live cmux proof.

## Evidence and chosen approach

- A loopback docs server was opened with `cmux new-pane --type browser` in the
caller's workspace. It rendered the existing sidebar, sanitized Markdown,
syntax highlighting, live reload, and Mermaid without taking focus.
- `forgectl docs [dir|file ...]` will start the existing foreground loopback
server, open its URL in a right-hand cmux browser pane when
`CMUX_WORKSPACE_ID` is present, and otherwise fall back to the system browser.
The terminal remains the server owner, so Ctrl-C stops the preview.
- `docs serve`, `docs open`, and `docs list` keep their explicit contracts.
`docs serve --open` remains the spelling for a separate system-browser tab.
- Add an unobtrusive reading-settings control for body, heading, and code font
families plus text size, line height, and measure. Settings are browser-local
and persist with local storage, making this exploratory without expanding the
config schema.
- Rewrite relative Markdown image URLs to a same-origin resource endpoint. The
endpoint will resolve files through the indexed root's existing containment
boundary, reject excluded/hidden paths and unsupported media types, and keep
remote images blocked by the current content-security policy.
- Remove the terminal explorer, Kitty graphics, Glamour, and pure-Go Mermaid
code and dependencies. The completed native-reader plan remains in history as
the record of the explored approach and this plan records the deliberate
pivot.

## Alternatives deferred

- A background daemon would return the invoking terminal immediately, but it
introduces lifecycle and stale-process questions before the reading model is
proven.
- Bundling proprietary or large font binaries would make typography identical
across machines, but system/local font stacks are sufficient to evaluate the
interaction first.
- Opening a generic external browser inside forgectl would couple the command
to browser automation. This experiment uses cmux's supported CLI when it is
present and preserves the portable system-browser fallback.

## Checklist

- [x] Prove a loopback reader can open in the caller's cmux workspace without
stealing focus.
- [x] Persist and commit the approved pivot before implementation.
- [x] Replace the bare docs/TUI entry point with embedded-cmux preview startup.
- [x] Add persisted reading typography and measure controls.
- [x] Serve contained local Markdown images through the loopback reader.
- [x] Remove terminal-reader code and dependencies.
- [x] Update help and README; leave generated changelog prose to Release Please.
- [x] Run fresh build, vet, tests, formatting, lint, and live cmux acceptance.
- [x] Update and push the existing pull request, then monitor its checks.

## Acceptance

- From a cmux terminal, `forgectl docs [dir|file ...]` creates a readable
right-hand browser pane in that same workspace and leaves keyboard focus in
the invoking terminal; Ctrl-C stops its foreground server.
- Markdown, syntax highlighting, tables, Mermaid, inline SVG, and contained
relative raster/SVG images render without a network dependency.
- The reader offers visibly different body, heading, and code font choices and
persists the chosen typography, size, line height, and content width.
- Outside cmux, the same command opens the system browser and explains the
server lifecycle; explicit `serve`, `open`, and `list` behavior remains green.
60 changes: 60 additions & 0 deletions docs/plans/2026-08-29-native-docs-explorer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Native docs explorer

> Superseded by [Embedded docs preview](2026-08-29-embedded-docs-preview.md)
> after live reading acceptance showed the rasterized terminal surface was not
> competitive with the existing HTML renderer. Retained as the exploration
> record, not the current implementation plan.

## Goal

Make `forgectl docs` a terminal-native, Artificer-styled document explorer while
retaining the existing HTTP reader for remote, phone, and exact Mermaid.js
fallback use. Local images, SVG, and Mermaid diagrams render in compatible
terminals through the Kitty graphics protocol and degrade to readable text when
graphics are unavailable.

## Chosen approach

- `forgectl docs [dir|file ...]` launches the TUI on an interactive terminal;
`forgectl docs browse` is the explicit equivalent. Existing `serve`, `open`,
and `list` behavior remains compatible.
- Reuse the docs index, root resolution, watcher, and browser opener. Add an
adaptive Bubble Tea explorer with a filterable tree and scrollable Markdown
pane, rendered with Glamour and an Artificer stylesheet.
- Use Charm's already-pinned Kitty encoder with Unicode virtual placements.
Add capability detection, stable IDs, resize retransmission, and cleanup.
- Render local PNG, JPEG, static GIF, and SVG references only. Resolve every
path relative to its Markdown document and keep it inside the indexed root.
- Render Mermaid with a pinned pure-Go renderer and rasterizer. Unsupported
syntax remains visible as source and points to the retained web reader.
- External links require confirmation before opening the system browser;
relative Markdown links and anchors navigate inside the TUI.

## Alternatives declined

- Full HTTP-reader replacement: loses remote and phone access and removes the
exact Mermaid.js fallback.
- Headless Chrome: matches Mermaid.js more closely but makes a browser a hidden
runtime dependency, contrary to the native-reader goal.
- Text-only first release: does not deliver the requested graphics experience.

## Checklist

- [x] Create an isolated worktree and feature branch from fresh `origin/main`.
- [x] Persist and commit the approved plan before implementation.
- [x] Add terminal Markdown, resource, diagram, and Kitty graphics primitives.
- [x] Add the adaptive docs TUI and wire the native-first CLI entry points.
- [x] Cover fallback, containment, navigation, resize, reload, and cleanup.
- [x] Update help and README; leave generated changelog prose to Release Please.
- [x] Run fresh build, vet, tests, formatting, lint, and Ghostty acceptance.

## Acceptance

- Markdown, local raster images, SVG, and supported Mermaid diagrams render in
cmux/Ghostty; scrolling and resizing keep images attached to document rows.
- Unsupported terminals, remote images, invalid diagrams, and decode failures
show deliberate readable fallbacks without raw graphics control sequences.
- Live reload, internal links, history, filtering, and external-link
confirmation work without opening a separate browser for ordinary reading.
- Existing `docs serve`, `docs open`, and `docs list` contracts and tests remain
green.
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ require (
github.com/microcosm-cc/bluemonday v1.0.27
github.com/muesli/termenv v0.16.0
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.9
github.com/yuin/goldmark v1.8.4
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
golang.org/x/net v0.55.0
golang.org/x/sys v0.46.0
golang.org/x/term v0.44.0
)
Expand Down Expand Up @@ -58,9 +60,7 @@ require (
github.com/muesli/roff v0.1.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.37.0 // indirect
)
33 changes: 20 additions & 13 deletions internal/cli/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ import (
"github.com/cameronsjo/forgectl/internal/module"
)

// docsModule declares the local markdown reader extension (ADR-0005): owns
// the [docs] config section. See forgectl#93 for the full design; this is
// PR1's slice (render + index, no live reload).
// docsModule declares the local markdown reader extension (ADR-0005) and owns
// the [docs] config section. See forgectl#93 for the full design.
var docsModule = module.Manifest{
Name: "docs",
Tier: module.TierExtension,
Expand All @@ -21,25 +20,33 @@ var docsModule = module.Manifest{
// shape.
func newDocsCmd(deps module.Deps) *cobra.Command {
cmd := &cobra.Command{
Use: "docs",
Short: "Local markdown reader — render + serve an indexed doc set over loopback HTTP",
Use: "docs [dir|file ...]",
Short: "Read an indexed Markdown doc set in an embedded HTML preview",
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if handled, err := docsHelpForNonTTY(cmd, args); handled {
return err
}
return runDocsPreview(cmd, deps, args)
},
Long: `docs is forgectl's local markdown reader (forgectl#93): pure-Go
server-side rendering (goldmark+GFM, class-based chroma highlighting,
bluemonday sanitization), Artificer-themed, served over loopback HTTP so it
behaves the same whether you're at the machine or SSH'd in from the headless
workbench — no terminal-specific rendering, no popping between windows.
rendering with an Artificer-themed HTML preview as the ordinary path. Inside
cmux, the preview opens as a right-hand browser pane in the caller's workspace
without taking focus. Elsewhere it opens in the system browser. The invoking
terminal owns the foreground loopback server; press Ctrl-C there to stop it.

forgectl docs [dir|file ...] serve + open the reading preview
forgectl docs serve [dir|file ...] render + serve an indexed doc set
forgectl docs serve --open also open the system browser
forgectl docs open [path] point the browser at a doc on the
already-running reader
forgectl docs list [dir|file ...] list the indexed docs, no server
forgectl docs list --json machine-readable output for scripts

Diagrams render in the page: a fenced code block tagged mermaid becomes a live
diagram themed from the same Artificer tokens as the rest of the reader, and
both those and inline SVG pan and zoom (drag to pan, modifier-scroll or
click-then-scroll to zoom, double-click or 0 to reset).
Mermaid.js renders fenced mermaid blocks, and Mermaid and inline SVG can pan and
zoom (drag to pan, modifier-scroll or click-then-scroll to zoom, double-click or
0 to reset). Reading settings in the app bar control body, heading, and code
fonts plus text size, line height, and content width.

With no arguments, both verbs index cwd, ./docs (if present), and
$CADENCE_FIELD_REPORTS_DIR (if set), plus any extra roots configured in the
Expand Down
41 changes: 41 additions & 0 deletions internal/cli/docs_preview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package cli

import (
"fmt"

"github.com/spf13/cobra"
"golang.org/x/term"

docspkg "github.com/cameronsjo/forgectl/internal/docs"
"github.com/cameronsjo/forgectl/internal/module"
)

var docsStreamIsTerminal = func(stream any) bool {
fd, ok := stream.(interface{ Fd() uintptr })
return ok && term.IsTerminal(int(fd.Fd()))
}

func runDocsPreview(cmd *cobra.Command, deps module.Deps, args []string) error {
if !docsStreamIsTerminal(cmd.InOrStdin()) || !docsStreamIsTerminal(cmd.OutOrStdout()) {
return fmt.Errorf("docs preview requires an interactive terminal; use `forgectl docs list` for text output or `forgectl docs serve` for a server-only process")
}
roots, err := resolveDocsRoots(args, deps.Cfg.Docs)
if err != nil {
return err
}
idx, err := docspkg.NewIndex(roots)
if err != nil {
return err
}
return runDocsPreviewServer(cmd, deps, idx)
}

func docsHelpForNonTTY(cmd *cobra.Command, args []string) (bool, error) {
if docsStreamIsTerminal(cmd.InOrStdin()) && docsStreamIsTerminal(cmd.OutOrStdout()) {
return false, nil
}
if len(args) == 0 {
return true, cmd.Help()
}
return true, fmt.Errorf("docs preview requires an interactive terminal; use `forgectl docs list` for text output or `forgectl docs serve` for a server-only process")
}
56 changes: 56 additions & 0 deletions internal/cli/docs_preview_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package cli

import (
"bytes"
"context"
"strings"
"testing"

"github.com/cameronsjo/forgectl/internal/config"
forgexec "github.com/cameronsjo/forgectl/internal/exec"
"github.com/cameronsjo/forgectl/internal/module"
)

func TestDocsCommand_NonTTYBareInvocationKeepsHelpBehavior(t *testing.T) {
previous := docsStreamIsTerminal
docsStreamIsTerminal = func(any) bool { return false }
t.Cleanup(func() { docsStreamIsTerminal = previous })

cmd := newDocsCmd(module.Deps{Cfg: config.Config{}, Runner: &forgexec.FakeRunner{}})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetIn(strings.NewReader(""))
cmd.SetArgs(nil)
if err := cmd.ExecuteContext(context.Background()); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "serve + open the reading preview") || !strings.Contains(out.String(), "docs serve") {
t.Fatalf("help = %q", out.String())
}
}

func TestDocsPreview_RejectsNonTTY(t *testing.T) {
previous := docsStreamIsTerminal
t.Cleanup(func() { docsStreamIsTerminal = previous })
deps := module.Deps{Cfg: config.Config{}, Runner: &forgexec.FakeRunner{}}
cmd := newDocsCmd(deps)
cmd.SetIn(strings.NewReader(""))
cmd.SetOut(new(bytes.Buffer))
docsStreamIsTerminal = func(any) bool { return false }
if err := runDocsPreview(cmd, deps, nil); err == nil || !strings.Contains(err.Error(), "interactive terminal") {
t.Fatalf("non-TTY error = %v", err)
}
}

func TestDocsCommand_RegistersPreviewAndServerEntrypoints(t *testing.T) {
cmd := newDocsCmd(module.Deps{Cfg: config.Config{}, Runner: &forgexec.FakeRunner{}})
for _, name := range []string{"serve", "open", "list"} {
if found, _, err := cmd.Find([]string{name}); err != nil || found.Name() != name {
t.Fatalf("Find(%q) = %v, %v", name, found, err)
}
}
if found, _, err := cmd.Find([]string{"browse"}); err == nil && found.Name() == "browse" {
t.Fatal("obsolete terminal browse command is still registered")
}
}
Loading
Loading