Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a6cb06a
fix(desktop): wailskit MCP/impersonation readers snapshot activeChatB…
Oct 25, 2026
35ec1ce
test(config): characterize instance hooks clear-to-empty silently los…
Oct 25, 2026
41a5dc5
test(memory): repro GC forget-digest path violates #3880 tombstone co…
Oct 27, 2026
0a5c88a
fix(ratchet): drop error-marker gate in reactive rule injection (#4605)
Oct 30, 2026
82aae76
fix(agent): verify fallback carries raw output tail for marker-free f…
Oct 30, 2026
0238028
feat(tui): linear no-Bubble-Tea frontend for --plain and TERM=dumb (#…
Oct 30, 2026
b0a23d0
fix(tui): line-oriented HOME-dir confirm for linear frontend (#4604)
Oct 30, 2026
7a1b574
test(tui): cover ConfirmHomeDirPlain input semantics (#4604)
Oct 30, 2026
087549e
feat(plain): provider/endpoint management subcommands (#4604 follow-up)
Oct 30, 2026
ca2ddaf
test(provider): ForceDiscoverModels bypasses within-TTL cache (#4604)
Oct 30, 2026
7dd73cb
fix(plain): HOME confirm ran BOTH dialogs - branch by mode first (#4604)
Oct 30, 2026
f5389ef
feat(plain): i18n catalogs + /lang runtime switch (#4604)
Oct 30, 2026
1da8484
test(plain): /lang switching semantics on the nil-cfg path (#4604)
Oct 30, 2026
97bd01f
docs: plain mode guide + --plain in CLI reference (#4604)
Oct 30, 2026
b8b7d74
feat(plain): opt out of autopilot injections, keep bypass surface (#4…
Oct 30, 2026
d820eef
test(agent): SuppressAutopilotInjection contract (#4604)
Oct 30, 2026
b97c25d
docs: plain mode suppresses autopilot injections (#4604)
Oct 30, 2026
48914ff
feat(plain): tool call status suffix (ok)/(error) (#4604)
Oct 30, 2026
e2fe97d
Merge remote-tracking branch 'origin/main' into feat-4604-linear
Oct 30, 2026
092b1f5
test(plain): extract stream printer, cover pairing contract (#4604)
Oct 30, 2026
ee5bc6d
docs: plain mode output format - tool status lines (#4604)
Oct 30, 2026
c7a9e4d
fix(plain): tool status pairing - turn boundary is RunStream return (…
Oct 30, 2026
1d233db
feat(plain): --persist-session opt-in message persistence (#4604)
Oct 30, 2026
c069ebf
test: pin --persist-session CLI surface - opt-in default off (#4604)
Oct 30, 2026
4b51470
docs: --persist-session flag in cli-reference and plain guide (#4604)
Oct 30, 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
661 changes: 661 additions & 0 deletions cmd/ggcode/plain.go

Large diffs are not rendered by default.

162 changes: 162 additions & 0 deletions cmd/ggcode/plain_cmd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package main

import (
"io"
"os"
"strings"
"testing"

"github.com/topcheer/ggcode/internal/config"
)

// TestPlainArgsAreFlags verifies the subcommand-vs-positional switch routing:
// leading "--" selects the flag mode, everything else is the switch form.
func TestPlainArgsAreFlags(t *testing.T) {
cases := []struct {
args []string
want bool
}{
{nil, false},
{[]string{"zai"}, false},
{[]string{"zai", "ep1"}, false},
{[]string{"--list"}, true},
{[]string{"--all"}, true},
{[]string{"--show-models"}, true},
}
for _, c := range cases {
if got := plainArgsAreFlags(c.args); got != c.want {
t.Errorf("plainArgsAreFlags(%v) = %v, want %v", c.args, got, c.want)
}
}
}

// newPlainListFixture builds an in-memory config with three endpoints covering
// the filter classes: keyed+modelled, keyed+no-models, no-key+modelled.
func newPlainListFixture() *config.Config {
cfg := &config.Config{
Vendor: "vkeyed",
Endpoint: "ep-both",
Vendors: map[string]config.VendorConfig{
"vkeyed": {Endpoints: map[string]config.EndpointConfig{
"ep-both": {
APIKey: "k",
Models: []string{"m1", "m2", "m3", "m4", "m5", "m6", "m7", "m8", "m9", "m10", "m11", "m12"},
DefaultModel: "m1",
},
}},
"vnomodels": {APIKey: "k", Endpoints: map[string]config.EndpointConfig{
"ep-nomodels": {APIKey: "k"},
}},
"vnokey": {Endpoints: map[string]config.EndpointConfig{
"ep-nokey": {Models: []string{"m1"}},
}},
},
}
return cfg
}

// captureStdout runs fn with os.Stdout piped and returns what it printed.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Skipf("pipe: %v", err)
}
os.Stdout = w
fn()
_ = w.Close()
os.Stdout = old
b, _ := io.ReadAll(r)
_ = r.Close()
return string(b)
}

// TestPlainListEndpointsDefaultFilter verifies the default view lists only
// endpoints with BOTH a usable api key and a configured model list, caps the
// model listing at 10 with a +N more hint, and marks the active endpoint.
func TestPlainListEndpointsDefaultFilter(t *testing.T) {
cfg := newPlainListFixture()
out := captureStdout(t, func() { plainListEndpoints(cfg, false) })

if !strings.Contains(out, "vendor vkeyed") {
t.Error("keyed+modelled vendor should be listed")
}
if strings.Contains(out, "vnomodels") || strings.Contains(out, "ep-nomodels") {
t.Error("keyed-but-modelless endpoint must be hidden by default")
}
if strings.Contains(out, "vnokey") || strings.Contains(out, "ep-nokey") {
t.Error("keyless endpoint must be hidden by default")
}
if !strings.Contains(out, "(+2 more") {
t.Errorf("12 models should cap at 10 with '(+2 more' hint, got:\n%s", out)
}
if !strings.Contains(out, "* ep-both") {
t.Errorf("active endpoint should carry the '*' marker, got:\n%s", out)
}
if strings.Count(out, " - ")+strings.Count(out, " * ") != 10 {
t.Errorf("default view must show at most 10 models, got:\n%s", out)
}
}

// TestPlainListEndpointsAll verifies --all surfaces the filtered-out classes
// with their annotations.
func TestPlainListEndpointsAll(t *testing.T) {
cfg := newPlainListFixture()
out := captureStdout(t, func() { plainListEndpoints(cfg, true) })

for _, want := range []string{"ep-both", "ep-nomodels", "(no models configured)", "ep-nokey", "(no api key)"} {
if !strings.Contains(out, want) {
t.Errorf("--all output should contain %q, got:\n%s", want, out)
}
}
}

// TestPlainAddEndpointValidation verifies the guard rails that run BEFORE any
// config mutation: required fields, protocol whitelist, duplicate id.
func TestPlainAddEndpointValidation(t *testing.T) {
cfg := newPlainListFixture()

out := captureStdout(t, func() { plainAddEndpoint(cfg, "", "openai", "https://x", "m", 0, 0, "") })
if !strings.Contains(out, "required") {
t.Errorf("missing name must be rejected, got: %s", out)
}

out = captureStdout(t, func() { plainAddEndpoint(cfg, "ep9", "grpc", "https://x", "m", 0, 0, "") })
if !strings.Contains(out, "unsupported endpoint-type") {
t.Errorf("unknown protocol must be rejected, got: %s", out)
}

out = captureStdout(t, func() { plainAddEndpoint(cfg, "ep-both", "openai", "https://x", "m", 0, 0, "") })
if !strings.Contains(out, "already exists") {
t.Errorf("duplicate endpoint id must be rejected, got: %s", out)
}

if _, ok := cfg.Vendors["vkeyed"].Endpoints["ep-both"]; !ok {
t.Fatal("fixture endpoint vanished - validation must not mutate config")
}
}

// TestPlainAddModelValidation verifies the add-model guards: model required,
// active endpoint must exist, duplicate model refused.
func TestPlainAddModelValidation(t *testing.T) {
cfg := newPlainListFixture()

out := captureStdout(t, func() { plainAddModel(cfg, "", "") })
if !strings.Contains(out, "required") {
t.Errorf("empty model must be rejected, got: %s", out)
}

out = captureStdout(t, func() { plainAddModel(cfg, "m1", "") })
if !strings.Contains(out, "already on endpoint") {
t.Errorf("duplicate model must be rejected, got: %s", out)
}

// No active endpoint: refuse instead of creating one.
cfg2 := newPlainListFixture()
cfg2.Vendor = ""
out = captureStdout(t, func() { plainAddModel(cfg2, "newmodel", "") })
if !strings.Contains(out, "no active endpoint") {
t.Errorf("missing active endpoint must be rejected, got: %s", out)
}
}
179 changes: 179 additions & 0 deletions cmd/ggcode/plain_i18n.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package main

// Linear-frontend i18n (#4604): every user-visible string of the plain REPL
// goes through plainTr. Two catalogs are maintained (en, zh-CN) keyed by
// tui.Language; other configured languages fall back to English. The catalog
// lives here (not in internal/tui's 1300-line table) so the linear frontend
// stays self-contained and its strings can be audited separately.

import (
"strings"

"github.com/topcheer/ggcode/internal/config"

"github.com/topcheer/ggcode/internal/tui"
)

var plainCatalogs = map[tui.Language]map[string]string{
tui.LangEnglish: {
"banner": "ggcode linear frontend (no TUI) - /help for commands, Ctrl-D to exit",
"askuser.off": "[plain] ask_user disabled - the agent ends its turn and you answer by typing the next line",
"notice": "[notice]",
"prompt": "ggcode> ",
"help.title": "commands:",
"help.status": " /status show active vendor/endpoint/model",
"help.model": " /model <name> hot-switch model (persists)",
"help.provider": " /provider <vendor> [ep] switch vendor (optionally endpoint)",
"help.plist": " /provider --list [--all] list endpoints+models (default: keyed+modelled only)",
"help.padd": " /provider --add-endpoint ... add endpoint to active vendor",
"help.endpoint": " /endpoint <id> switch endpoint on current vendor",
"help.emodels": " /endpoint --show-models all models on the active endpoint",
"help.erefresh": " /endpoint --refresh-models force model refetch, then show",
"help.eadd": " /endpoint --add-model ... add a model to the active endpoint",
"help.ekey": " /endpoint --set-apikey ... set the API key on the active endpoint",
"help.lang": " /lang <en|zh-CN> switch interface language (persists)",
"help.exit": " /exit leave the REPL (also Ctrl-D)",
"help.tail": "anything else is sent to the agent; approvals default to Accept",
"help.tail2": "(GGCODE_PLAIN_APPROVE=deny rejects instead); ask_user is disabled.",
"cfg.unavailable": "[plain] config unavailable",
"agent.unavailable": "[plain] agent unavailable",
"resolve.failed": "[plain] resolve failed:",
"switch.failed": "[plain] switch failed:",
"switch.nopersist": "[plain] switch applied but not persisted:",
"switch.ok": "[plain] now on vendor=%s endpoint=%s model=%s",
"usage.model": "usage: /model <name>",
"usage.provider": "usage: /provider <vendor> [endpoint] | --list [--all] | --add-endpoint ...",
"usage.endpoint": "usage: /endpoint <id> | --show-models | --refresh-models | --add-model ... | --set-apikey ...",
"cmd.unknown": "[plain] unknown command:",
"opt.unknown": "[plain] unknown /provider option:",
"opt.unknown.ep": "[plain] unknown /endpoint option:",
"flags.bad": "[plain] bad flags:",
"lang.usage": "usage: /lang <en|zh-CN>",
"lang.unknown": "[plain] unsupported language:",
"lang.switched": "[plain] language switched to %s (persisted)",
"lang.nopersist": "[plain] language switched but not persisted:",
"add.req": "[plain] --endpoint-name/--endpoint-type/--endpoint-url are required",
"add.proto": "[plain] unsupported endpoint-type %q (want openai|openai-responses|anthropic|gemini|copilot)",
"add.novendor": "[plain] no active vendor to add the endpoint to",
"add.duplicate": "[plain] endpoint %q already exists on vendor %q",
"add.ok": "[plain] endpoint %q added to vendor %q%s",
"add.switchhint": " - switch with: /endpoint ",
"list.vendor": "vendor %s:",
"list.noendpoints": "vendor %s: (no endpoints)",
"list.nokey": " (no api key)",
"list.nomodels": " (no models configured)",
"list.more": " (+%d more - use /endpoint --show-models)",
"models.title": "models on %s/%s:",
"models.discovered": "(discovered)",
"models.none": " (none - use /endpoint --add-model --model <id>)",
"models.nodiscovery": "[plain] model discovery unavailable (showing configured list):",
"addmodel.req": "[plain] --model is required",
"addmodel.noactive": "[plain] no active endpoint to add the model to",
"addmodel.nofound": "[plain] active endpoint %q not found on vendor %q",
"addmodel.duplicate": "[plain] model %q already on endpoint %q",
"addmodel.ok": "[plain] model %q added to endpoint %q - switch with: /model %s",
"setkey.req": "[plain] --apikey is required",
"setkey.noactive": "[plain] no active endpoint to set the key on",
"setkey.nofound": "[plain] active endpoint %q not found on vendor %q",
"setkey.ok": "[plain] api key set on endpoint %q",
},
tui.LangZhCN: {
"banner": "ggcode 线性前端(无 TUI)- /help 查看命令,Ctrl-D 退出",
"askuser.off": "[plain] ask_user 已禁用 - 模型结束回合后,输入下一行即是你的回答",
"notice": "[提示]",
"prompt": "ggcode> ",
"help.title": "命令:",
"help.status": " /status 查看当前 vendor/endpoint/模型",
"help.model": " /model <名称> 热切换模型(持久化)",
"help.provider": " /provider <vendor> [ep] 切换 vendor(可选 endpoint)",
"help.plist": " /provider --list [--all] 列出 endpoint 与模型(默认仅显示已配 key 且有模型的)",
"help.padd": " /provider --add-endpoint ... 向当前 vendor 添加 endpoint",
"help.endpoint": " /endpoint <id> 在当前 vendor 内切换 endpoint",
"help.emodels": " /endpoint --show-models 当前 endpoint 的全部模型",
"help.erefresh": " /endpoint --refresh-models 强制刷新模型列表后显示",
"help.eadd": " /endpoint --add-model ... 向当前 endpoint 添加模型",
"help.ekey": " /endpoint --set-apikey ... 设置当前 endpoint 的 API key",
"help.lang": " /lang <en|zh-CN> 切换界面语言(持久化)",
"help.exit": " /exit 退出(也可 Ctrl-D)",
"help.tail": "其余输入直接发给模型;审批默认接受",
"help.tail2": "(GGCODE_PLAIN_APPROVE=deny 可改为拒绝);ask_user 已禁用。",
"cfg.unavailable": "[plain] 配置不可用",
"agent.unavailable": "[plain] agent 不可用",
"resolve.failed": "[plain] 解析失败:",
"switch.failed": "[plain] 切换失败:",
"switch.nopersist": "[plain] 已切换但未持久化:",
"switch.ok": "[plain] 当前 vendor=%s endpoint=%s 模型=%s",
"usage.model": "用法:/model <名称>",
"usage.provider": "用法:/provider <vendor> [endpoint] | --list [--all] | --add-endpoint ...",
"usage.endpoint": "用法:/endpoint <id> | --show-models | --refresh-models | --add-model ... | --set-apikey ...",
"cmd.unknown": "[plain] 未知命令:",
"opt.unknown": "[plain] 未知的 /provider 选项:",
"opt.unknown.ep": "[plain] 未知的 /endpoint 选项:",
"flags.bad": "[plain] 参数错误:",
"lang.usage": "用法:/lang <en|zh-CN>",
"lang.unknown": "[plain] 不支持的语言:",
"lang.switched": "[plain] 已切换语言为 %s(已持久化)",
"lang.nopersist": "[plain] 已切换但未持久化:",
"add.req": "[plain] --endpoint-name/--endpoint-type/--endpoint-url 为必填",
"add.proto": "[plain] 不支持的 endpoint-type %q(应为 openai|openai-responses|anthropic|gemini|copilot)",
"add.novendor": "[plain] 没有活动 vendor 可添加 endpoint",
"add.duplicate": "[plain] endpoint %q 已存在于 vendor %q",
"add.ok": "[plain] endpoint %q 已添加到 vendor %q%s",
"add.switchhint": " - 切换命令:/endpoint ",
"list.vendor": "vendor %s:",
"list.noendpoints": "vendor %s:(无 endpoint)",
"list.nokey": " (未配 api key)",
"list.nomodels": " (无已配置模型)",
"list.more": " (还有 %d 个 - 用 /endpoint --show-models 查看)",
"models.title": "%s/%s 上的模型:",
"models.discovered": "(在线发现)",
"models.none": " (暂无 - 用 /endpoint --add-model --model <id> 添加)",
"models.nodiscovery": "[plain] 模型发现不可用(显示已配置列表):",
"addmodel.req": "[plain] 必须指定 --model",
"addmodel.noactive": "[plain] 没有活动 endpoint 可添加模型",
"addmodel.nofound": "[plain] 活动 endpoint %q 不在 vendor %q 上",
"addmodel.duplicate": "[plain] 模型 %q 已在 endpoint %q 上",
"addmodel.ok": "[plain] 模型 %q 已添加到 endpoint %q - 切换命令:/model %s",
"setkey.req": "[plain] 必须指定 --apikey",
"setkey.noactive": "[plain] 没有活动 endpoint 可设置 key",
"setkey.nofound": "[plain] 活动 endpoint %q 不在 vendor %q 上",
"setkey.ok": "[plain] 已设置 endpoint %q 的 api key",
},
}

// plainLang is the linear frontend's active language: initialized from
// cfg.Language at startup, hot-switched by /lang (which also persists to
// cfg.Language so TUI/pipe sessions inherit the choice).
var plainLang = tui.LangEnglish

// plainTr looks up key in the active language catalog, falling back to
// English, then to the key itself (missing translation should never blank
// the output).
func plainTr(lang tui.Language, key string) string {
if c, ok := plainCatalogs[lang]; ok {
if v, ok := c[key]; ok {
return v
}
}
if v, ok := plainCatalogs[tui.LangEnglish][key]; ok {
return v
}
return key
}

// plainLangFromConfig derives the frontend language from cfg.Language,
// defaulting to English (same convention as the TUI's startup path).
func plainLangFromConfig(cfgLanguage string) tui.Language {
if strings.TrimSpace(cfgLanguage) == "" {
return tui.LangEnglish
}
return tui.NormalizeLanguage(cfgLanguage)
}

// cfgLang extracts cfg.Language nil-safely.
func cfgLang(cfg *config.Config) string {
if cfg == nil {
return ""
}
return cfg.Language
}
Loading
Loading