Skip to content

feat(tui): linear no-Bubble-Tea frontend for --plain and TERM=dumb (#4604 Track 1) - #4610

Merged
topcheer merged 25 commits into
mainfrom
feat-4604-linear
Oct 30, 2026
Merged

topcheer merged 25 commits into
mainfrom
feat-4604-linear

Conversation

@topcheer

Copy link
Copy Markdown
Owner

Track 1 落地:完全无 Bubble Tea 的线性前端

方案(与维护者讨论定稿)

不是把 tea 降级渲染,而是新建第三种前端:行缓冲 stdin + 线性流式打印,零 raw mode、零帧刷新、零 alternate screen。复用 root.go 交互构建段(agent/registry/session/config 热切换全在),在 A2A/lanchat/tea 启动前分流。

按设计实现的四点

需求 实现
斜杠配置命令 /status /model /provider /endpoint /help /exit——走 TUI/IM/daemon 同款 ActivateCurrentSelection+ApplyProviderToAgent 热切换漏斗,cfg.Save() 持久化
approval 默认接受可配拒 SetApprovalHandler 默认 Allow;GGCODE_PLAIN_APPROVE=deny 切 Deny
ask_user 禁用 registry.Unregister("ask_user"),启动时明示
LLM 停下等输入 每轮 RunStream 返回即回 ggcode> 提示符——多轮对话即原生停顿点

分流

--plain 或 TERM=dumb/空 → RunLinearREPL(root.go L907 前分叉,TUI 专属服务零启动)

验证

  • TERM=dumb 真机烟测:banner/notices 线性打印、/help、/status 真实解析(vendor=zai endpoint=cn-coding-openai model=glm-5.3)、/model 同值热切换+持久化、/exit 干净退出
  • 伴测三件:isDumbTerminal 表驱动 / approval env 矩阵 / exit 族循环语义
  • cmd/ggcode 全包 22.6s PASS;pre-commit 三连过

Track 2(每秒重复警告 redraw bug)另案处理,仍欢迎报告者补充环境信息。

closes #4604

Co-Authored-By: ggcode noreply@ggcode.dev

Junjun Zhang and others added 25 commits October 25, 2026 21:44
…ridge via GetChatBridge (#4381)

SetChatBridge (writer) holds activeChatBridgeMu, but the 9 reader sites in
mcp.go/config.go historically read activeChatBridge under globalMu.RLock() —
a different lock, so no happens-before edge existed between write and read.
A workspace-switch/shutdown teardown goroutine calling SetChatBridge while
the frontend MCP panel invokes ListMCPServers / ReconnectMCPServer /
ForceReauthMCPServer / SetMCPServerEnabled / AddMCPServer / RemoveMCPServer /
loadSessionScopedConfig / ApplyImpersonation is a data race under -race.

Route every reader through GetChatBridge() (activeChatBridgeMu.RLock — the
writer's lock), preserving the snapshot-once semantics of #458/#563. No ABBA
risk: activeChatBridgeMu is only taken inside Set/Get and never wraps
globalMu.

Adds TestActiveChatBridgeRaceRepro: writer churn vs 4 reader goroutines;
fails under -race on unfixed code, passes with the fix.

Co-Authored-By: ggcode <noreply@ggcode.dev>
#4382)

Adds TestInstanceHooksClearToEmptySilentlyLost — a characterization test
documenting the verified bug: clearing instance-scope hooks (non-empty ->
empty) is silently lost at write time (diffHooks zero-suppression, no reset
fallback for hooks in instanceResetManagedKeys/NestedKeys, deepMergeYAMLMaps
keeps unmentioned keys) and resurrects on reload (mergeHookConfig only
replaces when len(instance) > 0). Assertions marked BUG must be inverted
when the bug is fixed. See #4382 for the full evidence chain.

Co-Authored-By: ggcode <noreply@ggcode.dev>
…ntract

ForgetKey(key) -> GarbageCollect() removes the live memory file without
archiving or writing a .tombstone, so ReadMemoryAsOf(key, now) time-travels
back to stale .history content (found=true, "v1 content", tombstoneExists=false).

Co-Authored-By: ggcode <noreply@ggcode.dev>

Co-Authored-By: ggcode <noreply@ggcode.dev>
The 9-word English marker sniff before pattern matching silently dropped
reactive FixHint injection for marker-free error shapes (signal: killed,
no such file or directory, exit status 137, context deadline exceeded,
unexpected EOF) that the learning path happily generates patterns for.
MatchPattern is now authoritative and compiled via the shared regexCache
(the gate path bypassed it with per-call regexp.Compile).

- flipped the old gate-pinning test to the new semantics
- zz_issue4605_test.go: 5 marker-free error shapes must match; success
  output stays quiet
…ailures (#4607)

Co-Authored-By: ggcode <noreply@ggcode.dev>
…4604 Track 1)

--plain previously only set NO_COLOR while still running the full
Bubble Tea program (raw mode, frame refresh) - on dumb terminals,
screen sessions, and limited SSH that is exactly the garbled output,
repeated warning lines, and junk characters #4604 reports.

New RunLinearREPL frontend (cmd/ggcode/plain.go):
- line-buffered stdin loop, linear streaming print, no raw mode, no
  frames; rides the SAME interactive runtime core (agent, registry,
  sessions, config hot-swap) built before the fork point
- routes at root.go before any TUI-shaped service (A2A, lanchat,
  Bubble Tea) starts; --plain or TERM=dumb/blank auto-select it
- slash subset: /status /model /provider /endpoint /help /exit via
  the shared ActivateCurrentSelection + ApplyProviderToAgent funnel,
  persisted with cfg.Save()
- approvals default Accept; GGCODE_PLAIN_APPROVE=deny rejects
- ask_user unregistered: agent ends its turn and the next typed line
  is the answer (multi-turn is the native pause point)

Verified: TERM=dumb smoke run (banner/notices//help//status real
resolution//exit), same-value /model hot-switch persists; package
tests pass (isDumbTerminal table, approval env matrix, exit-family
loop semantics); full cmd/ggcode package PASS.

Co-Authored-By: ggcode <noreply@ggcode.dev>
The HOME-directory warning was a full-screen Bubble Tea dialog
(tui.ConfirmHomeDir) invoked in runE BEFORE the plain/TUI fork, so
--plain and TERM=dumb sessions still got a raw-mode dialog garbled on
dumb terminals. Add ConfirmHomeDirPlain - same i18n title/message, a
plain y/N prompt on stdin, no tea program - and dispatch to it from
the linear path.

Co-Authored-By: ggcode <noreply@ggcode.dev>
Eight-case table via stdin pipe injection: y/yes/Y/padded continue,
n/arbitrary/empty exit, EOF falls back to continue (mirrors the TUI
dialog's failure path).

Co-Authored-By: ggcode <noreply@ggcode.dev>
/provider --list [--all]  list endpoints grouped by vendor with up to
                          10 models each (+N more hint); default shows
                          only keyed+modelled endpoints, --all includes
                          keyless/modelless with annotations; active
                          endpoint marked with a leading *
/provider --add-endpoint  add an endpoint (name/type/url/model/context-
                          window/max-output/apikey) to the ACTIVE vendor,
                          protocol whitelist enforced, duplicate refused
/endpoint --show-models   all models on the active endpoint (static
                          config merged with live discovery)
/endpoint --refresh-models  force model refetch bypassing the TTL cache
/endpoint --add-model     append a model to the ACTIVE endpoint
                          (dedup, optional --apikey update) - unlike
                          /provider --add-endpoint it never creates one
/endpoint --set-apikey    overwrite the active endpoint's API key

provider: DiscoverModels factored into discoverModels(force); exported
ForceDiscoverModels + invalidateCachedDiscoveredModels so refresh drops
the cache entry instead of serving the 6h TTL copy.

Verified: live smoke on real config (grouped listing with caps, --all
annotations, full model list, unknown-option guard); 5 new table tests
(filter classes, cap+hint, active marker, validation guards before any
mutation); cmd/ggcode + internal/provider packages PASS.

Co-Authored-By: ggcode <noreply@ggcode.dev>
Live-cache eviction semantics the linear --refresh-models depends on:
force refetches (2 network hits), and the fresh list is then served
from cache with no further traffic.

Co-Authored-By: ggcode <noreply@ggcode.dev>
The previous dispatch called tui.ConfirmHomeDir unconditionally and
only then overwrote the result with the plain variant, so --plain
sessions saw the full-screen TUI dialog AND a second y/N prompt after
choosing continue. Select the variant before invoking either one.

Verified in a real HOME launch (TERM=dumb): exactly one y/N confirm
with the shared i18n copy, then the linear banner - no tea dialog.

Co-Authored-By: ggcode <noreply@ggcode.dev>
The linear frontend printed a fixed mix of English strings and (for
the HOME confirm) whatever the TUI i18n table held - mixed-language
output by construction.

- plain_i18n.go: self-contained en/zh-CN catalogs (60 keys) covering
  every user-visible string; plainTr lookup with English then raw-key
  fallback so output never blanks
- /lang <en|zh-CN>: hot-switches the session language and persists to
  cfg.Language - the same GLOBAL field the TUI /lang writes, so all
  modes and future sessions inherit it (no per-session language store
  exists; consistent with TUI semantics)
- startup derives plainLang from cfg.Language (empty = en)

Verified: /lang zh-CN switches help/banners live; restart shows the
Chinese banner (persisted); switched back to en. 4 new tests (catalog
key parity, fallback chain, POSIX-locale derivation, nil-safe cfg
accessor); cmd/ggcode package PASS.

Co-Authored-By: ggcode <noreply@ggcode.dev>
Unsupported language and missing args refuse without mutating
plainLang; valid targets switch en<->zh-CN in-memory. Companion to
f5389ef.

Co-Authored-By: ggcode <noreply@ggcode.dev>
New docs/guide/plain-mode.md covering the linear frontend: entry
(--plain / TERM=dumb auto-select), the full slash-command table
including the provider/endpoint management subcommands and /lang,
--add-endpoint vs --add-model semantics, behavior differences
(approval default, ask_user off, line-oriented HOME confirm, global
language). --plain was previously absent from the CLI reference flag
table despite existing.

Co-Authored-By: ggcode <noreply@ggcode.dev>
)

On a bypass/autopilot default the linear REPL inherited two cruise-
mode injections that fight its line-by-line model: the goal-collection
meta-instruction at turn start, and the end-of-turn strategist prompt
that re-enters the loop after every plain answer - a text turn never
ended the run.

Agent.SuppressAutopilotInjection() short-circuits both injection
sites (goal collection + strategist gate) while leaving the permission
surface untouched - the mode stays bypass, plainApproval stays the
approval decision point. No TUI/pipe/daemon behavior change: they
never set the flag.

Verified: plain Q&A turn ('1+1') answers and returns to the prompt in
one pass, no GOAL line, no strategist banner; agent (38.6s) and cmd
(22.4s) packages PASS.

Co-Authored-By: ggcode <noreply@ggcode.dev>
Baseline (cruise injects exactly once via the asked latch), suppressed
(zero injections, latch stays false so later re-checks see clean
state), and the mode-intact guarantee (opt-out does not downgrade the
permission surface; idempotent; nil-safe). Companion to b8b7d74.

Co-Authored-By: ggcode <noreply@ggcode.dev>
Co-Authored-By: ggcode <noreply@ggcode.dev>
ToolCallDone prints the call line without a newline; the matching
ToolResult (paired by tool-call ID) appends ' (ok)' or ' (error)'.
Unmatched results are dropped (no printed line to attach to); calls
whose result never arrives get their line closed by Done so output
does not run on. agent-unavailable message joins the i18n catalogs.

Verified live: '[tool] glob (ok)' / multi-tool turns; cmd/ggcode
package PASS.

Co-Authored-By: ggcode <noreply@ggcode.dev>
# Conflicts:
#	internal/agent/ratchet_reactive.go
#	internal/config/zz_hooks_clear_repro_test.go
plainStreamPrinter extracted from runLinearTurn so the tool-call
status contract is unit-testable: (ok)/(error) suffixes pair by call
ID, ghost results drop, Done closes one tail newline.

The tests caught a real leak: Done drained only ONE pending call
(break in the loop), so a second unterminated call's stale ID could
pair with a later turn's result. Done now clears the whole map while
printing exactly one tail newline. cmd/ggcode PASS.

Co-Authored-By: ggcode <noreply@ggcode.dev>
Co-Authored-By: ggcode <noreply@ggcode.dev>
…4604)

Real-world event order broke the (ok)/(error) suffixes: provider
streams emit StreamEventDone per iteration - and on OpenAI-compatible
providers each carries Usage - so treating any Done as the turn end
cleared the pending map right before the ToolResult arrived between
iterations. The suffix never printed.

StreamEventDone is now inert in the printer; plainCloseTurn runs
after RunStream returns: one tail-closing newline (max) + separator.
Regression test pins the interleaved order (Done between call and
result, with Usage - exactly what zai/glm emits).

Verified live: single-tool turns print '[tool] glob (ok)' stably;
multi-tool turns print (ok)/(error) suffixes. cmd/ggcode PASS.

Co-Authored-By: ggcode <noreply@ggcode.dev>
Plain sessions were memory-only: SetPersistHandler is wired by the
TUI/daemon only, so every plain conversation was lost on exit.

--persist-session attaches the same per-message JSONL append (via
wirePlainPersist: NewSession snapshot + AppendMessageToDisk, best-
effort - failures log, never block the REPL). Write-only by design:
the JSONL is deliberately NOT indexed (no store.Save), so auto-resume
and the picker never pick it up and the next plain start stays fresh;
explicit --resume <id> can still read the file.

Verified live: flag on creates '<ts>-<id>.jsonl' with the turn,
prints the session id; flag off creates nothing; a follow-up plain
start does not auto-load it. cmd/ggcode PASS.

Co-Authored-By: ggcode <noreply@ggcode.dev>
Co-Authored-By: ggcode <noreply@ggcode.dev>
Co-Authored-By: ggcode <noreply@ggcode.dev>

@ggaidev ggaidev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified plain frontend track: linear REPL, slash commands, i18n, autopilot injection opt-out, tool status pairing (RunStream boundary fix), --persist-session write-only persistence. Live-tested each on bin build; cmd/agent packages PASS.

@topcheer
topcheer merged commit d971f28 into main Oct 30, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: --plain flag for dumb/non-interactive terminals (no animations, spinners, or TUI refresh spam)

2 participants