From 1f49dd29369f956bbc65efa3ed534529b439f07b Mon Sep 17 00:00:00 2001 From: Franco Conturso Date: Wed, 11 Nov 2026 17:18:13 -0300 Subject: [PATCH] Terminal: no "not running" line over the grid; the dimmed grid shows the state (#745, D290) --- docs/DECISIONS.md | 3 ++- docs/spec/Terminal.md | 2 +- internal/app/frame.go | 15 +++++++------- internal/app/frame_terminal_test.go | 31 ++++++++++++++++++++++++++--- internal/app/terminallook_test.go | 7 +++++-- 5 files changed, 43 insertions(+), 15 deletions(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 1e23747a..89f6ac0c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -75,7 +75,7 @@ replaces it. | D77 | 2026-11-03 | The right panel is 380 wide (was 440). Below a window width of 1300 the panel hides and `board ▴` in the bottom row becomes `panel ▴`; a click floats the panel over the terminal (360 wide, 8 from the edges). Esc with the focus, `×` or a click outside closes it. The orchestrator tab with the panel open has no `board ▴`; worker tabs keep it. | Native · final screens 6 and 7. The floating panel is an overlay, so the narrow terminal keeps its size (D67). | | D78 | 2026-11-03 | `docs/DESIGN.md` v0.2 comes from the canvas page Native · final, the projects modal artboards (screen 5, SideFinal) and the PR viewer artboards (PrView, PrViewFiles); the answer card and context sheet come from screens 1 and 2 of the archived Final page, which Native · final keeps "unchanged". Mockup G (v0.1) and screens 1 to 4 and 6 of Final are archived, except that card and sheet. Parts the code does not have are marked "not built"; the open points are listed at the end of the file. | Owner scope update on #127. Builders work from `docs/DESIGN.md`, so it must describe the new design before the view slices start. | | D79 | 2026-11-04 | `ghostty.Conn` (#238 step 1) is the plugin's `Options.Conn` over an hc terminal and does what `HCLink` (D43) did, except emulating. It attaches on a goroutine with `history` 1000 and the view's size (the first attach waits for the first `ResizePixels`, or 500 ms), and attaches again with `resumed: true` after a lost connection: backoff 500 ms doubled up to 5 s, only while the window shows (`SetVisible`), reset by data. Its `Read` waits through the loss and returns EOF only after `Close`, because the plugin ends the terminal on EOF and draws "[Disconnected]". A failure that is final (`finished`, `not_found`, `machine_unknown`, `incompatible`, `needs_framed`, or a `StreamEnd`) retries only on the core's `restarted` event; input and the first size start an attach, a later size does not. Input typed while there is no live stream is held (at most 64 KiB, in order, sent first once live); a final failure drops it and `Write` returns `ErrNotRunning`. State changes go to `Options.OnState` as `Connecting`, `Live`, `NotRunning` or `Incompatible`. `Close` also closes the source. `CoreSource` is the framed dial of `terminal.CoreSource`, moved. The observer attach (D45) and `resize_notifications` are not in `Conn`: see step 3 of #238. | The plugin owns emulation, scrollback and drawing, so the link keeps only what the stream needs. EOF from `Read` would end the plugin's terminal for good, so a reconnect could not keep its screen. | -| D80 | 2026-11-04 | The observer part is replaced by D91 (contr watches again). The frame shows the plugin's terminal (`terminal.View`) for the selected orchestrator or worker (#238 step 2), over a `ghostty.Conn` (D79); switching agent closes the old terminal and its source, and stopping the frame closes the last one. Theme and font come from `internal/app/theme`: `theme.Text` (the plugin's `Foreground`) and `theme.BgTerminal`, `TerminalPalette` (the old `Palette16`), JetBrains Mono 13 with ligatures off, row height 20 (`TerminalLineHeightScale`), padding 8. The grid is dimmed to 50% while the connection is not live, and a line shows over it when the program is not running (`herdr.NotRunningLine`) or the core refused contr (the message). The window's visibility goes to `Conn.SetVisible`, the window's focus to `Conn.SetFocus`; the plugin sends focus reports to the program itself. **The observer attach of D45 is dropped (owner, 2026-11-04):** contr takes the terminal's size. The app attaches with input and the view's size as soon as an agent is selected, so another viewer's size does not survive. | The plugin needs the Conn to give it the size it fits, and the owner chose that contr takes the size. | +| D80 | 2026-11-04 | The observer part is replaced by D91 (contr watches again). The frame shows the plugin's terminal (`terminal.View`) for the selected orchestrator or worker (#238 step 2), over a `ghostty.Conn` (D79); switching agent closes the old terminal and its source, and stopping the frame closes the last one. Theme and font come from `internal/app/theme`: `theme.Text` (the plugin's `Foreground`) and `theme.BgTerminal`, `TerminalPalette` (the old `Palette16`), JetBrains Mono 13 with ligatures off, row height 20 (`TerminalLineHeightScale`), padding 8. The grid is dimmed to 50% while the connection is not live, and a line shows over it when the program is not running (`herdr.NotRunningLine`; replaced by D290: no line) or the core refused contr (the message). The window's visibility goes to `Conn.SetVisible`, the window's focus to `Conn.SetFocus`; the plugin sends focus reports to the program itself. **The observer attach of D45 is dropped (owner, 2026-11-04):** contr takes the terminal's size. The app attaches with input and the view's size as soon as an agent is selected, so another viewer's size does not survive. | The plugin needs the Conn to give it the size it fits, and the owner chose that contr takes the size. | | D81 | 2026-11-04 | The old terminal code is removed (#238 step 4): `internal/term`, `internal/term/vt`, `internal/app/terminal`, `cmd/gridbench`, `third_party/vt` with its `go.mod` replace, and the charmbracelet dependencies. Nothing else used them. `check.sh` no longer tests the `third_party/vt` module. The e2e tests drive the app through MyGo's automation and did not use the old packages, so they stay as they were. | The frame shows the plugin's terminal (D80), so about 16,800 lines of emulator, view, link and test code had no user. | | D82 | 2026-11-04 | A plain terminal is an hc terminal: it survives closing contr, agents can read it, and it uses the same view as agent terminals (#246; owner: "Open one with Cmd-T and a "+" in the tab bar, as a new tab in the current space"). It has labels `contr=agent`, `role=terminal`, `project=` and `worktree=`, and runs `$SHELL -l` (fallback `/bin/zsh`) with `outlive_parent` and `no_parent`; the name is `term-`. It is a tab of the open project after the workers, with the shell's title (OSC) as label, else `$ `. It is not in the sidebar agents section, the board or the needs row (`Snapshot.Terminals`, `state.KindTerminal`). The `+` after the tabs and Cmd-T (File menu, New Terminal) open one in the open project, in the first orchestrator's worktree, else in the home folder with project "" (no folder picker). The open runs off the UI thread on its own hc connection (`NewTerminal`, as D57); a second click while one runs does nothing; an error shows in the status bar as `Terminal: `; the new tab is selected. **The terminal area no longer shows herdr's line:** hc terminals work while herdr is down, so the line moved to the Spaces column, under the host card. Cmd-W to close a plain terminal is D85. | The owner wants a shell in contr without herdr. Tabs, agents and the board already come from hc, so only the terminal area depended on herdr. | | D83 | 2026-11-04 | Inside hc, `agents.Manager` sends `HC_TERMINAL_ID` as `caller` on `terminal.kill`, `terminal.read` and `terminal.interrupt` (#259). | hc types an exit note into a parent when its child exits and the parent has not seen that news. `terminal.kill` counts the exit as seen only when the caller is the parent. Without `caller`, each stop by the coordinator typed a "exited (signal 9)" note into it. A stop from the app has no `HC_TERMINAL_ID`, so the coordinator still hears that the owner stopped its worker. | @@ -263,3 +263,4 @@ replaces it. | D286 | 2026-11-11 | PR and issue chips show GitHub's Octicons (#714, slice B; docs/spec/ChatView.md, Chips and Blocks). A PR shows the pull request icon of its state: open, draft, merged or closed; an issue the issue icon, open or closed. The icons are in `assets/icons/github` (Octicons, MIT, in NOTICE) and `icons.PRIcon`, `icons.IssueIcon` map gh's states to them. They take an ink token (`ink-48`), never GitHub's colors (D119, D261). The dot of the checks (running, passed, failed) stays after the icon, so the chip still shows CI (replaced by D288); the open and closed dots go, as the icon says it. The read of a number (`gh pr view`, D262) adds `isDraft`; no other gh read is added. The board's PR cell shows the same icon. The work rows of the board panel (hidden, D147) are not changed. | The owner asked for GitHub's icons (#714). | | D287 | 2026-11-11 | A fenced `contr-seq` block in Claude's answer draws as a sequence diagram (#696, PR 2; ChatView.md, Diagrams), in package `internal/app/diagram` like the flow (D284): actors as columns with a dashed lifeline, one line per message (`A -> B: text`, `A --> B: text` for a dashed reply, `A -> A: text` to itself), tokens only and the chat text size. A block that does not parse or is too large (more than 6 actors or 16 messages, an actor over 28 or a message over 40 characters, wider than the column) shows as its code block. The `chat-writing` skill tells Claude when and how to write a `contr-flow` and a `contr-seq` block, and that other diagrams stay as text. | Issue #696, step 2. | | D288 | 2026-11-11 | PR and issue chips have no checks dot; the Octicon's color shows the state (#741; docs/spec/ChatView.md, Chips and Blocks). It replaces the ink-48 icon and the dot of D286. While a PR is open its checks win: failed `content-failed`, running `content-running`, passed `content-done`; then open `content-done`, draft `ink-48`. Merged `content-link` and closed `ink-48`, so a closed PR does not read as failed. An issue: open `content-done`, closed `content-link`. The palette has no purple, so merged and a closed issue take `content-link`, the nearest hue; no token is added. The number keeps `content-link`. The tooltip and the label still name the state. The board's PR cell does the same. | The owner asked to remove the dots and show the state by color (#741). | +| D290 | 2026-11-11 | The terminal shows no "not running" line over the grid (#745, owner: "its better that this doesnt exist"; docs/spec/Terminal.md, States). It replaces the not running line of D80. While the program is not running or the connection is lost, the grid stays and is dimmed to 50% (`staleOpacity`); the dim shows the state. The lines for "terminal unavailable: …" and an incompatible core stay. | diff --git a/docs/spec/Terminal.md b/docs/spec/Terminal.md index 85e69341..b7628cfd 100644 --- a/docs/spec/Terminal.md +++ b/docs/spec/Terminal.md @@ -40,7 +40,7 @@ Design system page: component Terminal. Shared rules: [README.md](README.md). - **Focused:** the cursor blinks; keys go to the terminal. - **Not focused:** the cursor is a hollow box. -- **Process ended:** a last line in `ink-48`; the tab closes on stop. +- **Process ended or connection lost:** the grid stays, dimmed to 50%, with no line over it (D290); the tab closes on stop. ## Behavior diff --git a/internal/app/frame.go b/internal/app/frame.go index 29719852..95598173 100644 --- a/internal/app/frame.go +++ b/internal/app/frame.go @@ -1164,10 +1164,6 @@ func (f *Frame) syncTerminal() { f.setTerminalFocus(f.winFocused) } -// TerminalNotRunningLine shows over the grid while the terminal's program is -// not running, or the connection to hc is lost. -const TerminalNotRunningLine = "not running" - // coreVersion is the hc core's version while the agents follow the core. func (f *Frame) coreVersion() (string, bool) { if f.agentSnap == nil { @@ -1181,10 +1177,15 @@ func (f *Frame) coreVersion() (string, bool) { // screen on show may be out of date. const staleOpacity = 0.5 +// gridDimmed is true while the connection is not live: the grid then draws at +// staleOpacity, with no line over it (D290). +func (f *Frame) gridDimmed() bool { return f.termState.Kind != ghostty.Live } + // terminalLine is the line shown instead of, or over, the grid, with its // colour. ok is false when the grid speaks for itself, and while no terminal is // open: emptyTerminal draws that state. The reasons the terminal cannot show -// are ink-48; the line of an ended process is ink-48 (docs/spec/Terminal.md). +// are ink-48. An ended process or a lost connection has no line: the dimmed +// grid shows it (D290). func (f *Frame) terminalLine() (line string, colour ui.Color, ok bool) { p := theme.Current() if f.term == nil { @@ -1194,8 +1195,6 @@ func (f *Frame) terminalLine() (line string, colour ui.Color, ok bool) { return "", ui.Color{}, false } switch f.termState.Kind { - case ghostty.NotRunning: - return TerminalNotRunningLine, p.Ink48, true case ghostty.Incompatible: return f.termState.Message, p.Ink48, true } @@ -1226,7 +1225,7 @@ func (f *Frame) buildTerminal(c *ui.Context) { f.find.refocus = false grid.Focus() } - if f.termState.Kind != ghostty.Live { + if f.gridDimmed() { grid.Opacity(staleOpacity) } if f.focusTerm { diff --git a/internal/app/frame_terminal_test.go b/internal/app/frame_terminal_test.go index e4bd45e2..89a9149e 100644 --- a/internal/app/frame_terminal_test.go +++ b/internal/app/frame_terminal_test.go @@ -128,17 +128,39 @@ func TestTerminalGridIsInsideTheTerminalAreaAndShowsNoLineWhileLive(t *testing.T if top, left, right, bottom := grid.Y-area.Y, grid.X-area.X, area.X+area.W-(grid.X+grid.W), area.Y+area.H-(grid.Y+grid.H); top != theme.Space14 || left != theme.Space20 || right != theme.Space20 || bottom != theme.Space12 { t.Errorf("grid insets top %v left %v right %v bottom %v, want 14 20 20 12", top, left, right, bottom) } - if got := strings.Join(tt.Texts(), "\n"); strings.Contains(got, TerminalNotRunningLine) || strings.Contains(got, TerminalOffLine) { + if got := strings.Join(tt.Texts(), "\n"); strings.Contains(got, "not running") || strings.Contains(got, TerminalOffLine) { t.Errorf("a live terminal shows a line: %q", got) } } +// An ended program shows the dimmed grid and no line (D290). +func TestAnEndedTerminalShowsADimmedGridAndNoLine(t *testing.T) { + f, w, tt, _ := terminalFrame(t, map[string]error{"t2": &hc.Error{Kind: "finished", Message: "done"}}) + f.Selection.Select(state.Selected{Kind: state.KindWorker, ID: "t2"}) + for deadline := time.Now().Add(3 * time.Second); f.termState.Kind != ghostty.NotRunning && time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) { + w.flush() + tt.Frame() + } + if f.termState.Kind != ghostty.NotRunning { + t.Fatalf("state %+v, want not running", f.termState) + } + tt.Frame() + if _, ok := tt.Find(LabelTerminalGrid); !ok { + t.Error("the grid should stay") + } + if !f.gridDimmed() { + t.Error("the grid should be dimmed") + } + if got := strings.Join(tt.Texts(), "\n"); strings.Contains(got, "not running") || strings.Contains(got, "done") { + t.Errorf("an ended terminal shows a line: %q", got) + } +} + func TestTerminalSlotShowsWhyItIsNotRunning(t *testing.T) { for name, tc := range map[string]struct { err error want string }{ - "finished": {&hc.Error{Kind: "finished", Message: "done"}, TerminalNotRunningLine}, "incompatible": {&hc.Error{Kind: hc.KindIncompatible, Message: "core too old"}, "core too old"}, } { t.Run(name, func(t *testing.T) { @@ -200,7 +222,10 @@ func TestAHiddenWindowDoesNotRetryAndShowingItDoes(t *testing.T) { w.flush() // before the terminal exists: the terminal must start hidden by itself f.Selection.Select(state.Selected{Kind: state.KindWorker, ID: "t2"}) tt := ui.NewTester(f.buildTerminal, 600, 400) - waitLine(t, w, tt, TerminalNotRunningLine) + for deadline := time.Now().Add(3 * time.Second); f.termState.Kind != ghostty.NotRunning && time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) { + w.flush() + tt.Frame() + } time.Sleep(1200 * time.Millisecond) // longer than the first backoff step if n, _ := src.counts(); n != 1 { t.Fatalf("attaches while hidden = %d, want 1", n) diff --git a/internal/app/terminallook_test.go b/internal/app/terminallook_test.go index fee90e4a..154dfeb2 100644 --- a/internal/app/terminallook_test.go +++ b/internal/app/terminallook_test.go @@ -146,8 +146,11 @@ func TestTerminalLinesUseTheirTokens(t *testing.T) { w.flush() } f.termState = ghostty.State{Kind: ghostty.NotRunning} - if line, c, ok := f.terminalLine(); !ok || line != TerminalNotRunningLine || c != p.Ink48 { - t.Errorf("dark=%v: ended line %q colour %v, want ink-48 %v", p.Dark, line, c, p.Ink48) + if line, _, ok := f.terminalLine(); ok { + t.Errorf("dark=%v: an ended program shows the line %q, want none (D290)", p.Dark, line) + } + if !f.gridDimmed() { + t.Errorf("dark=%v: the grid of an ended program is not dimmed", p.Dark) } f.termState = ghostty.State{Kind: ghostty.Incompatible, Message: "core too old"} if line, c, ok := f.terminalLine(); !ok || line != "core too old" || c != p.Ink48 {