Compare commits

...
27 changed files with 822 additions and 14 deletions

No files matched your search

+1 -1
View File
@@ -121,7 +121,7 @@ reasonix config compact-ratio 75 # set the user-global default
reasonix config compact-ratio --local 75 # override in ./reasonix.toml
```
The editable range is 65–85%, with 85% as the built-in default. Lower values
The editable range is 30–85%, with 85% as the built-in default. Lower values
compact earlier and may reduce prompt-prefix cache reuse; higher values retain
more context before compaction. Project `reasonix.toml` takes precedence over
the user config. Changes apply to new CLI sessions; an already-running session
+1 -1
View File
@@ -108,7 +108,7 @@ reasonix config compact-ratio 75 # 设置用户全局默认值
reasonix config compact-ratio --local 75 # 写入 ./reasonix.toml 项目覆盖
```
可设置范围为 65–85%,内置默认值为 85%。数值越低越早压缩,可能降低 prompt prefix
可设置范围为 30–85%,内置默认值为 85%。数值越低越早压缩,可能降低 prompt prefix
缓存复用率;数值越高则会在压缩前保留更多上下文。项目 `reasonix.toml` 的优先级高于
用户全局配置。修改会应用于新启动的 CLI 会话;已经运行的会话继续使用启动时加载的阈值。
+1 -1
View File
@@ -665,7 +665,7 @@ Usage:
reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)
reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)
reasonix config reasoning-language [auto|zh|en] configure visible reasoning language
reasonix config compact-ratio [--local] [65..85] configure automatic compaction threshold
reasonix config compact-ratio [--local] [30..85] configure automatic compaction threshold
reasonix config telemetry [auto|on|off] configure content-free CLI usage metrics
reasonix report [list|show|send|delete] [ID] review and explicitly send local CLI crash reports
reasonix mcp <add|remove|list|import> manage MCP servers in reasonix.toml
+1 -1
View File
@@ -666,7 +666,7 @@ var Chinese = Messages{
reasonix acp [--model NAME] 通过 stdio 提供 Agent Client Protocol(也可用:reasonix --acp)
reasonix setup [path] 交互式配置向导;生成 reasonix.toml(及 .env)
reasonix config reasoning-language [auto|zh|en] 配置可见思考语言
reasonix config compact-ratio [--local] [65..85] 配置自动压缩阈值
reasonix config compact-ratio [--local] [30..85] 配置自动压缩阈值
reasonix config telemetry [auto|on|off] 配置不含内容的 CLI 使用统计
reasonix report [list|show|send|delete] [ID] 审阅并明确发送本地 CLI 崩溃报告
reasonix mcp <add|remove|list|import> 管理 reasonix.toml 里的 MCP 服务器
+1 -1
View File
@@ -599,7 +599,7 @@ var ChineseTraditional = Messages{
reasonix acp [--model NAME] 透過 stdio 提供 Agent Client Protocol(也可用:reasonix --acp)
reasonix setup [path] 互動式設定精靈;生成 reasonix.toml(及 .env)
reasonix config reasoning-language [auto|zh|en] 設定可見思考語言
reasonix config compact-ratio [--local] [65..85] 設定自動壓縮閾值
reasonix config compact-ratio [--local] [30..85] 設定自動壓縮閾值
reasonix config telemetry [auto|on|off] 設定不含內容的 CLI 使用統計
reasonix report [list|show|send|delete] [ID] 審閱並明確傳送本機 CLI 當機報告
reasonix mcp <add|remove|list|import> 管理 reasonix.toml 裡的 MCP 伺服器
+4 -4
View File
@@ -1603,8 +1603,8 @@ func configCompactRatioCommand(args []string) int {
return 0
}
percent, err := strconv.ParseFloat(strings.TrimSpace(rest[0]), 64)
if err != nil || math.IsNaN(percent) || math.IsInf(percent, 0) || percent < 65 || percent > 85 {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "compact ratio must be a percentage between 65 and 85")
if err != nil || math.IsNaN(percent) || math.IsInf(percent, 0) || percent < 30 || percent > 85 {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "compact ratio must be a percentage between 30 and 85")
return 2
}
ratio := percent / 100
@@ -1673,7 +1673,7 @@ func formatCompactRatioPercent(ratio float64) string {
func configUsage() {
fmt.Print(`Usage:
reasonix config reasoning-language [--local] [auto|zh|en]
reasonix config compact-ratio [--local] [65..85]
reasonix config compact-ratio [--local] [30..85]
reasonix config currency [auto|CNY|USD]
reasonix config telemetry [auto|on|off]
`)
@@ -1687,7 +1687,7 @@ func configTelemetryUsage() {
func configCompactRatioUsage() {
fmt.Print(`Usage:
reasonix config compact-ratio [--local] [65..85]
reasonix config compact-ratio [--local] [30..85]
`)
}
+2 -2
View File
@@ -745,14 +745,14 @@ func TestConfigCompactRatioLocalCreatesMinimalProjectOverride(t *testing.T) {
func TestConfigCompactRatioRejectsValuesOutsideEditableRange(t *testing.T) {
isolateCLIConfigHome(t)
for _, value := range []string{"64", "86", "NaN", "+Inf", "not-a-number"} {
for _, value := range []string{"29", "86", "NaN", "+Inf", "not-a-number"} {
t.Run(value, func(t *testing.T) {
errOut := captureStderr(t, func() {
if rc := Run([]string{"config", "compact-ratio", value}, "test-version"); rc != 2 {
t.Fatalf("config compact-ratio %s rc = %d, want 2", value, rc)
}
})
if !strings.Contains(errOut, "percentage between 65 and 85") {
if !strings.Contains(errOut, "percentage between 30 and 85") {
t.Fatalf("config compact-ratio %s stderr = %q", value, errOut)
}
})
@@ -0,0 +1,24 @@
package cli
import (
"testing"
"reasonix/internal/contract/config"
)
func TestCompactRatioAcceptsLegacyLowerRange(t *testing.T) {
for _, value := range []string{"30", "64"} {
t.Run(value, func(t *testing.T) {
isolateCLIConfigHome(t)
captureStdout(t, func() {
if rc := Run([]string{"config", "compact-ratio", value}, "test"); rc != 0 {
t.Fatalf("compact-ratio %s returned %d", value, rc)
}
})
cfg := config.LoadForEdit(config.UserConfigPath())
if cfg.Agent.CompactRatio < 0.30 || cfg.Agent.CompactRatio >= 0.65 {
t.Fatalf("saved ratio = %v", cfg.Agent.CompactRatio)
}
})
}
}
+49
View File
@@ -0,0 +1,49 @@
package tui
import (
"context"
"net/http"
)
type ModelChoice struct {
Ref string `json:"ref"`
Provider string `json:"provider"`
Model string `json:"model"`
Answers string `json:"answers,omitempty"`
Active bool `json:"active,omitempty"`
}
func (c *Client) Models(ctx context.Context) ([]ModelChoice, error) {
var out struct {
Models []ModelChoice `json:"models"`
}
err := c.do(ctx, http.MethodGet, "/models", nil, &out)
return out.Models, err
}
func (c *Client) SelectModel(ctx context.Context, ref string) error {
return c.do(ctx, http.MethodPost, "/model", map[string]string{"ref": ref}, nil)
}
type CapabilityChoice struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
State string `json:"state,omitempty"`
Enabled bool `json:"enabled"`
}
func (c *Client) Capabilities(ctx context.Context, kind string) ([]CapabilityChoice, error) {
var out struct {
Skills []CapabilityChoice `json:"skills"`
Servers []CapabilityChoice `json:"servers"`
}
err := c.do(ctx, http.MethodGet, "/"+kind, nil, &out)
if kind == "mcp" {
return out.Servers, err
}
return out.Skills, err
}
func (c *Client) SetCapabilityEnabled(ctx context.Context, kind, name string, enabled bool) error {
return c.do(ctx, http.MethodPost, "/"+kind+"/enabled", map[string]any{"name": name, "enabled": enabled, "scope": "project"}, nil)
}
@@ -0,0 +1,40 @@
package tui
import (
"context"
"net/http"
"net/http/httptest"
"reflect"
"testing"
)
func TestModelsDecodesCatalogEnvelope(t *testing.T) {
for _, tc := range []struct {
name string
body string
want []ModelChoice
}{
{
name: "choices",
body: `{"current":"fixture/chat","label":"chat","default":"fixture/chat","models":[{"ref":"fixture/chat","provider":"fixture","model":"chat","answers":"text","active":true}]}`,
want: []ModelChoice{{Ref: "fixture/chat", Provider: "fixture", Model: "chat", Answers: "text", Active: true}},
},
{name: "empty", body: `{"current":"","label":"","default":"","models":[]}`, want: []ModelChoice{}},
} {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/models" {
t.Errorf("request = %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(tc.body))
}))
defer srv.Close()
c := &Client{HTTP: srv.Client(), Base: srv.URL}
got, err := c.Models(context.Background())
if err != nil || !reflect.DeepEqual(got, tc.want) {
t.Fatalf("Models() = %+v, %v; want %+v", got, err, tc.want)
}
})
}
}
@@ -0,0 +1,63 @@
package tui_test
import (
"context"
"testing"
"time"
)
func TestModelCatalogAndSelectionThroughKernel(t *testing.T) {
c := inProcessKernel(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
models, err := c.Models(ctx)
if err != nil {
t.Fatal(err)
}
var ref string
for _, choice := range models {
if choice.Provider == "test-model" {
ref = choice.Ref
}
}
if ref == "" {
t.Fatalf("fixture model missing from catalog: %+v", models)
}
if err := c.SelectModel(ctx, ref); err != nil {
t.Fatal(err)
}
status, err := c.Status(ctx)
if err != nil || status.ModelRef != ref {
t.Fatalf("model after selection = %s, %v; want %s", status.ModelRef, err, ref)
}
}
func TestSkillActivationThroughKernel(t *testing.T) {
c := inProcessKernel(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
choices, err := c.Capabilities(ctx, "skills")
if err != nil {
t.Fatal(err)
}
if len(choices) == 0 {
t.Fatal("expected built-in skills")
}
choice := choices[0]
if err := c.SetCapabilityEnabled(ctx, "skills", choice.Name, !choice.Enabled); err != nil {
t.Fatal(err)
}
choices, err = c.Capabilities(ctx, "skills")
if err != nil {
t.Fatal(err)
}
for _, changed := range choices {
if changed.Name == choice.Name {
if changed.Enabled == choice.Enabled {
t.Fatal("activation switch did not reach catalog")
}
return
}
}
t.Fatal("toggled skill disappeared from management catalog")
}
+234
View File
@@ -0,0 +1,234 @@
package tui
import (
"strings"
"unicode/utf8"
tea "charm.land/bubbletea/v2"
"reasonix/internal/frontend/termrender"
)
type catalogRow struct {
name, detail, ref string
enabled bool
}
type catalogPicker struct {
kind string
rows []catalogRow
models []ModelChoice
query string
selected int
saving bool
add *mcpAddState
}
type catalogMsg struct {
kind string
models []ModelChoice
choices []CapabilityChoice
err error
}
type catalogSavedMsg struct {
picker *catalogPicker
err error
}
func (m *model) catalogSlash(line string) (tea.Cmd, bool) {
if line == "/mcp add" {
m.composer.Reset()
m.menu = nil
if m.tr.Running {
m.tr.AddNotice("warn", "wait for the current turn to finish")
return m.commit(), true
}
m.catalog = &catalogPicker{kind: "mcp", add: &mcpAddState{}}
return nil, true
}
kind := strings.TrimPrefix(line, "/")
if kind == "skill" {
kind = "skills"
}
switch kind {
case "model", "provider", "skills", "mcp":
default:
return nil, false
}
m.composer.Reset()
m.menu = nil
m.tr.AddEcho(line)
if m.tr.Running {
m.tr.AddNotice("warn", "wait for the current turn to finish")
return m.commit(), true
}
return tea.Batch(m.commit(), m.openCatalog(kind)), true
}
func (m *model) openCatalog(kind string) tea.Cmd {
return func() tea.Msg {
out := catalogMsg{kind: kind}
if kind == "model" || kind == "provider" {
out.models, out.err = m.client.Models(m.ctx)
} else {
out.choices, out.err = m.client.Capabilities(m.ctx, kind)
}
return out
}
}
func (m *model) onCatalog(msg catalogMsg) tea.Cmd {
if msg.err != nil {
m.tr.AddNotice("error", msg.kind+": "+msg.err.Error())
return m.commit()
}
p := &catalogPicker{kind: msg.kind, models: msg.models}
seen := map[string]bool{}
for _, c := range msg.models {
if c.Answers != "" && c.Answers != "text" {
continue
}
row := catalogRow{name: c.Ref, detail: c.Provider, ref: c.Ref, enabled: c.Active}
if p.kind == "provider" {
if seen[c.Provider] {
continue
}
seen[c.Provider] = true
row.name, row.detail, row.ref = c.Provider, "choose a model", c.Provider
}
p.rows = append(p.rows, row)
}
for _, c := range msg.choices {
p.rows = append(p.rows, catalogRow{name: c.Name, detail: c.Description, ref: c.Name, enabled: c.Enabled})
}
m.catalog = p
return nil
}
func (p *catalogPicker) filtered() []catalogRow {
var rows []catalogRow
for _, row := range p.rows {
if strings.Contains(strings.ToLower(row.name+" "+row.detail), strings.ToLower(p.query)) {
rows = append(rows, row)
}
}
return rows
}
func (m *model) catalogKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
p := m.catalog
if p == nil {
return nil, false
}
if p.saving {
return nil, true
}
if p.add != nil {
return m.mcpAddKey(msg), true
}
rows := p.filtered()
switch msg.String() {
case "esc", "ctrl+c":
m.catalog = nil
case "up", "ctrl+p":
p.selected = max(p.selected-1, 0)
case "down", "ctrl+n":
p.selected = min(p.selected+1, max(len(rows)-1, 0))
case "enter":
if len(rows) == 0 {
return nil, true
}
row := rows[min(p.selected, len(rows)-1)]
if p.kind == "provider" {
p.kind, p.rows, p.query, p.selected = "model", nil, "", 0
for _, c := range p.models {
if c.Provider == row.ref && (c.Answers == "" || c.Answers == "text") {
p.rows = append(p.rows, catalogRow{name: c.Ref, ref: c.Ref, enabled: c.Active})
}
}
return nil, true
}
p.saving = true
return func() tea.Msg {
var err error
if p.kind == "model" {
err = m.client.SelectModel(m.ctx, row.ref)
} else {
err = m.client.SetCapabilityEnabled(m.ctx, p.kind, row.ref, !row.enabled)
}
return catalogSavedMsg{picker: p, err: err}
}, true
case "backspace":
if p.query != "" {
_, n := utf8.DecodeLastRuneInString(p.query)
p.query, p.selected = p.query[:len(p.query)-n], 0
}
default:
if msg.String() == "ctrl+a" && p.kind == "mcp" {
p.add = &mcpAddState{}
return nil, true
}
if text := typedText(msg); text != "" {
p.query, p.selected = p.query+text, 0
}
}
return nil, true
}
func (m *model) onCatalogSaved(msg catalogSavedMsg) tea.Cmd {
if m.catalog != msg.picker {
return nil
}
m.catalog.saving = false
if msg.err != nil {
m.tr.AddNotice("error", msg.picker.kind+": "+msg.err.Error())
return m.commit()
}
kind := m.catalog.kind
m.catalog = nil
if kind == "model" {
return m.fetchStatus()
}
return m.openCatalog(kind)
}
func (m *model) catalogPanel() []string {
p := m.catalog
if p.add != nil {
return m.mcpAddPanel()
}
rows := p.filtered()
lines := []string{termrender.Accent(p.kind), " Search: " + p.query}
start := max(min(p.selected-4, len(rows)-8), 0)
for i := start; i < min(start+8, len(rows)); i++ {
row := rows[i]
label := row.name
if p.kind == "skills" || p.kind == "mcp" {
if row.enabled {
label += " [on]"
} else {
label += " [off]"
}
}
lines = append(lines, rowLine(i == p.selected, i+1, "", label, row.enabled))
if row.detail != "" {
lines = append(lines, termrender.Dim(" "+oneLine(row.detail, max(m.width-10, 1))))
}
}
if len(rows) == 0 {
lines = append(lines, termrender.Dim(" No matches"))
}
hint := "↑↓ navigate · type to search · Enter select · Esc cancel"
if p.kind == "skills" || p.kind == "mcp" {
hint = "↑↓ navigate · type to search · Enter toggle in project · Esc close"
}
if p.kind == "mcp" {
hint += " · Ctrl+A add"
}
if p.saving {
hint = "Saving…"
}
lines = append(lines, termrender.Dim(hint))
return panel(lines, m.width, accentEdge)
}
+1
View File
@@ -115,6 +115,7 @@ func (m *model) localCommands(line string) []CompletionItem {
return nil
}
cmds := []CompletionItem{{Label: "/resume", Insert: "/resume", Hint: i18n.M.CmdResume}}
cmds = append(cmds, CompletionItem{Label: "/paste-image", Insert: "/paste-image", Hint: i18n.M.CmdPasteImage})
for _, c := range []struct{ name, hint string }{
{"/queue", i18n.M.CmdQueue}, {"/steer", i18n.M.CmdSteer}, {"/takeover", i18n.M.CmdTakeover},
{"/status", i18n.M.CmdStatus}, {"/export", i18n.M.CmdExport}, {"/copy", i18n.M.CmdCopy},
+8 -2
View File
@@ -28,7 +28,9 @@ type Connection struct {
// SetupState is whether the session still owes a key, so the panel can open
// itself on a first run.
type SetupState struct {
Required bool `json:"required"`
Required bool `json:"required"`
Provider string `json:"provider,omitempty"`
KeyEnv string `json:"keyEnv,omitempty"`
}
func (c *Client) SetupState(ctx context.Context) (SetupState, error) {
@@ -113,7 +115,11 @@ func (m *model) onSetupState(msg setupStateMsg) tea.Cmd {
if msg.err != nil || !msg.state.Required || m.setup != nil {
return nil
}
return m.openSetup()
if msg.state.Provider != "" && msg.state.KeyEnv != "" {
m.tr.AddNotice("warn", fmt.Sprintf("provider %s missing env %s", msg.state.Provider, msg.state.KeyEnv))
m.tr.AddNotice("warn", "Selected model is missing its API key.")
}
return tea.Batch(m.commit(), m.openSetup())
}
func (m *model) onConnections(msg connectionsMsg) tea.Cmd {
+4
View File
@@ -147,6 +147,10 @@ func (m *model) stateText() string {
tag = " · " + termrender.Dim(i18n.M.MouseCaptureTag)
}
switch {
case m.setup != nil:
return footerValue(i18n.M.SetupTitle)
case m.catalog != nil:
return footerValue(m.catalog.kind + " picker")
case m.flashText() != "":
return termrender.Green(m.flashText()) + tag
case open != nil && open.Kind == ItemAsk:
+7
View File
@@ -56,6 +56,13 @@ func (m *model) onHelp(msg helpMsg) tea.Cmd {
}
}
builtin = append(builtin, m.localCommands("/")...)
filtered := builtin[:0]
for _, item := range builtin {
if item.Label != "/paste-image" {
filtered = append(filtered, item)
}
}
builtin = filtered
return m.emit(func(width int, _ bool) string {
var b strings.Builder
b.WriteString(termrender.Accent("commands") + "\n")
+16
View File
@@ -98,6 +98,12 @@ func (p *pasteStore) expand(s string) string {
// insertPaste puts pasted text in the composer, folded unless a panel is
// taking the keys: what is typed into one is an answer, not a message.
func (m *model) insertPaste(text string) {
if m.catalog != nil {
if s := m.catalog.add; s != nil && s.draft == nil && !s.busy && !m.catalog.saving {
s.input += text
}
return
}
if m.setup != nil {
m.pasteIntoSetup(text)
return
@@ -194,6 +200,9 @@ func (m *model) shortcutKey(k string) (tea.Cmd, bool) {
// screenKey takes what a key means before it reaches the composer: copying a
// selection, moving the transcript, answering an open panel.
func (m *model) screenKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
if cmd, handled := m.catalogKey(msg); handled {
return cmd, true
}
if cmd, handled := m.selectionKey(msg.String()); handled {
return cmd, true
}
@@ -264,6 +273,9 @@ func (m *model) send(steer bool) tea.Cmd {
if display == "" {
return nil
}
if cmd, ok := m.catalogSlash(display); ok {
return cmd
}
if cmd, ok := m.queueSlash(display); ok {
return cmd
}
@@ -298,6 +310,10 @@ func (m *model) send(steer bool) tea.Cmd {
}
m.tr.AddNotice("info", "reasonix "+version)
return m.commit()
case display == "/paste-image":
m.composer.Reset()
m.menu = nil
return m.pasteClipboard()
}
if cmd, ok := m.miscSlash(display); ok {
return cmd
+142
View File
@@ -0,0 +1,142 @@
package tui
import (
"context"
"net/http"
"strings"
"unicode/utf8"
tea "charm.land/bubbletea/v2"
"reasonix/internal/frontend/termrender"
)
type MCPDraftServer struct {
Name string `json:"name"`
Transport string `json:"transport"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
URL string `json:"url,omitempty"`
Env map[string]string `json:"env,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
}
type MCPDraftRisk struct {
Server string `json:"server"`
Kind string `json:"kind"`
Field string `json:"field"`
Detail string `json:"detail"`
}
type MCPDraft struct {
Servers []MCPDraftServer `json:"servers"`
Risks []MCPDraftRisk `json:"risks"`
}
func (c *Client) ParseMCP(ctx context.Context, input string) (MCPDraft, error) {
var out MCPDraft
err := c.do(ctx, http.MethodPost, "/mcp/parse", map[string]string{"input": input}, &out)
return out, err
}
func (c *Client) InstallMCP(ctx context.Context, server MCPDraftServer) error {
return c.do(ctx, http.MethodPost, "/mcp/install", map[string]any{"server": server, "scope": "user"}, nil)
}
type mcpAddState struct {
input string
draft *MCPDraft
busy bool
}
type mcpParsedMsg struct {
picker *catalogPicker
draft MCPDraft
err error
}
func (m *model) mcpAddKey(msg tea.KeyPressMsg) tea.Cmd {
p, s := m.catalog, m.catalog.add
if s.busy {
return nil
}
switch msg.String() {
case "esc", "ctrl+c":
if s.draft != nil {
s.draft = nil
} else {
m.catalog = nil
}
case "enter":
if s.draft != nil {
servers := s.draft.Servers
p.saving = true
return func() tea.Msg {
for _, server := range servers {
if err := m.client.InstallMCP(m.ctx, server); err != nil {
return catalogSavedMsg{picker: p, err: err}
}
}
return catalogSavedMsg{picker: p}
}
}
if strings.TrimSpace(s.input) == "" {
return nil
}
s.busy = true
input := s.input
return func() tea.Msg {
draft, err := m.client.ParseMCP(m.ctx, input)
return mcpParsedMsg{picker: p, draft: draft, err: err}
}
case "backspace":
if s.draft == nil && s.input != "" {
_, n := utf8.DecodeLastRuneInString(s.input)
s.input = s.input[:len(s.input)-n]
}
default:
if s.draft == nil {
s.input += typedText(msg)
}
}
return nil
}
func (m *model) onMCPParsed(msg mcpParsedMsg) tea.Cmd {
if m.catalog != msg.picker {
return nil
}
s := m.catalog.add
s.busy = false
if msg.err != nil {
m.tr.AddNotice("error", "mcp parse: "+msg.err.Error())
return m.commit()
}
s.draft = &msg.draft
return nil
}
func (m *model) mcpAddPanel() []string {
s := m.catalog.add
lines := []string{termrender.Accent("Add MCP server")}
if s.draft == nil {
lines = append(lines, " Paste an MCP declaration", " "+oneLine(s.input, max(m.width-8, 1)), termrender.Dim("Enter preview · Esc cancel"))
} else {
lines = append(lines, " Review before installing in user config:")
for _, server := range s.draft.Servers {
lines = append(lines, " "+server.Name+" · "+server.Transport)
lines = append(lines, " "+oneLine(strings.Join(append([]string{server.Command}, server.Args...), " "), max(m.width-10, 1)))
if server.URL != "" {
lines = append(lines, " "+oneLine(server.URL, max(m.width-10, 1)))
}
}
for _, risk := range s.draft.Risks {
lines = append(lines, termrender.Yellow(" "+oneLine(risk.Server+": "+risk.Detail, max(m.width-8, 1))))
}
lines = append(lines, termrender.Dim("Enter install and connect · Esc back"))
}
if s.busy || m.catalog.saving {
lines = append(lines, termrender.Dim("Working…"))
}
return panel(lines, m.width, accentEdge)
}
@@ -0,0 +1,91 @@
package tui
import (
"encoding/json"
"strings"
"testing"
)
func TestManagementCommandsOpenPanels(t *testing.T) {
for _, command := range []string{"/model", "/provider", "/skills", "/skill", "/mcp"} {
t.Run(command, func(t *testing.T) {
m, k := testModel(t)
typeText(m, command)
run(m, press(m, "enter"))
if calledWith(k, "POST /submit") {
t.Fatal("management panel command went to Submit")
}
if !strings.Contains(m.View().Content, "Search:") {
t.Fatal("management panel did not open")
}
run(m, press(m, "esc"))
if m.bottomLines().composerAt < 0 {
t.Fatal("cancel did not restore composer")
}
})
}
}
func TestSetupStatusLabelsTheOpenPanel(t *testing.T) {
m, _ := testModel(t)
typeText(m, "/setup")
run(m, press(m, "enter"))
if !strings.Contains(m.stateText(), "Configure connection") {
t.Fatalf("setup status = %s", m.stateText())
}
}
func TestPanelSelectionDrivesKernel(t *testing.T) {
for _, tc := range []struct{ command, call string }{
{"/model", `POST /model {"ref":"alpha/m1"}`},
{"/provider", `POST /model {"ref":"alpha/m1"}`},
{"/skills", `POST /skills/enabled {"enabled":false,"name":"review","scope":"project"}`},
{"/mcp", `POST /mcp/enabled {"enabled":true,"name":"docs","scope":"project"}`},
} {
t.Run(tc.command, func(t *testing.T) {
m, k := testModel(t)
typeText(m, tc.command)
run(m, press(m, "enter"))
if tc.command == "/provider" {
run(m, press(m, "enter"))
}
run(m, press(m, "enter"))
if !calledWith(k, tc.call) {
t.Fatalf("selection did not drive %s: %v", tc.call, k.seen())
}
})
}
}
func TestFirstRunWarningsUseKernelFacts(t *testing.T) {
m, _ := testModel(t)
var state SetupState
if err := json.Unmarshal([]byte(`{"required":true,"provider":"fixture","keyEnv":"FIXTURE_KEY"}`), &state); err != nil {
t.Fatal(err)
}
run(m, m.onSetupState(setupStateMsg{state: state}))
got := screenText(m)
for _, want := range []string{"provider fixture missing env FIXTURE_KEY", "Selected model is missing its API key."} {
if !strings.Contains(got, want) {
t.Fatalf("missing %q: %s", want, got)
}
}
}
func TestMCPAddRequiresPreviewAndConfirmation(t *testing.T) {
m, k := testModel(t)
typeText(m, "/mcp add")
run(m, press(m, "enter"))
m.insertPaste(`{"mcpServers":{"fixture":{"command":"fixture-server","args":["--serve"]}}}`)
run(m, press(m, "enter"))
if !calledWith(k, "POST /mcp/parse") || calledWith(k, "POST /mcp/install") {
t.Fatalf("preview should not install: %v", k.seen())
}
if got := m.View().Content; !strings.Contains(got, "fixture-server") || !strings.Contains(got, "Runs a local command") {
t.Fatalf("preview omits execution or risks: %s", got)
}
run(m, press(m, "enter"))
if !calledWith(k, "POST /mcp/install") {
t.Fatal("confirmed preview did not install")
}
}
@@ -0,0 +1,31 @@
package tui_test
import (
"context"
"strings"
"testing"
"time"
)
func TestRetiredPresetNoticeThroughKernel(t *testing.T) {
c := inProcessKernel(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stream := c.Subscribe(ctx)
for _, command := range []string{"/preset delivery", "/work-mode balanced", "/profile light"} {
if err := c.Submit(ctx, command); err != nil {
t.Fatal(err)
}
found := false
for !found {
select {
case update := <-stream:
if update.Event.Kind == "notice" && strings.Contains(update.Event.Text, "Execution modes have been retired.") {
found = true
}
case <-ctx.Done():
t.Fatalf("%s: retirement notice did not reach frontend", command)
}
}
}
}
+7
View File
@@ -111,6 +111,7 @@ type model struct {
copying *copyPicker
clearing *clearConfirm
setup *connectionSetup
catalog *catalogPicker
lastEsc time.Time // an idle Esc on an empty composer, arming the second
// frameRows is how tall the last inline frame was: a print has only the
// rows above it to land in.
@@ -385,6 +386,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// clipboard, the session list, the mouse and its timers.
func (m *model) onScreenMsg(msg tea.Msg) (tea.Cmd, bool) {
switch msg := msg.(type) {
case mcpParsedMsg:
return m.onMCPParsed(msg), true
case catalogMsg:
return m.onCatalog(msg), true
case catalogSavedMsg:
return m.onCatalogSaved(msg), true
case urlAnswerMsg:
return m.onURLAnswer(msg), true
case clipImageMsg:
+8
View File
@@ -31,6 +31,14 @@ func (k *recordingKernel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
k.calls = append(k.calls, r.Method+" "+r.URL.Path+" "+strings.TrimSpace(string(body)))
k.mu.Unlock()
switch r.URL.Path {
case "/models":
_ = json.NewEncoder(w).Encode(map[string]any{"current": "alpha/m1", "label": "m1", "default": "alpha/m1", "models": []map[string]any{{"ref": "alpha/m1", "provider": "alpha", "model": "m1", "answers": "text", "active": true}, {"ref": "beta/m2", "provider": "beta", "model": "m2", "answers": "text"}}})
case "/skills":
_ = json.NewEncoder(w).Encode(map[string]any{"skills": []map[string]any{{"name": "review", "description": "Review changes", "enabled": true}}})
case "/mcp":
_ = json.NewEncoder(w).Encode(map[string]any{"servers": []map[string]any{{"name": "docs", "state": "disabled", "enabled": false}}})
case "/mcp/parse":
_ = json.NewEncoder(w).Encode(map[string]any{"servers": []map[string]any{{"name": "fixture", "transport": "stdio", "command": "fixture-server", "args": []string{"--serve"}}}, "risks": []map[string]any{{"server": "fixture", "kind": "command", "detail": "Runs a local command"}}})
case "/complete":
if r.URL.Query().Get("line") == "/" {
_ = json.NewEncoder(w).Encode(map[string]any{"kind": "slash", "from": 0, "to": 1, "items": []map[string]any{
+3 -1
View File
@@ -22,6 +22,8 @@ func (m *model) bottomLines() bottom {
rows = append(rows, m.todoLines()...)
open := m.tr.OpenPrompt()
switch {
case m.catalog != nil:
rows = append(rows, m.catalogPanel()...)
case m.picker != nil:
rows = append(rows, m.pickerPanel()...)
case m.rewind != nil:
@@ -43,7 +45,7 @@ func (m *model) bottomLines() bottom {
rows = append(rows, w)
}
at := -1
if m.picker == nil && m.rewind == nil && m.copying == nil && m.clearing == nil && m.setup == nil && (open == nil || (open.Kind == ItemAsk && m.ask != nil && m.ask.entering())) {
if m.catalog == nil && m.picker == nil && m.rewind == nil && m.copying == nil && m.clearing == nil && m.setup == nil && (open == nil || (open.Kind == ItemAsk && m.ask != nil && m.ask.entering())) {
at = len(rows)
rows = append(rows, m.composerLines()...)
}
@@ -0,0 +1,46 @@
package control
import (
"strings"
"testing"
"reasonix/internal/contract/event"
)
func TestRemoteSlashIsHandledByController(t *testing.T) {
var notices []string
c := New(Options{Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e.Text)
}
})})
if !c.managementNotice("/remote") {
t.Fatal("/remote was not handled")
}
if len(notices) != 1 {
t.Fatalf("notices = %v", notices)
}
}
func TestRetiredPresetCommandsNeverChangeRuntime(t *testing.T) {
var notices []string
c := New(Options{Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e.Text)
}
})})
before := c.AgentPreset()
for _, cmd := range []string{"/preset delivery", "/work-mode balanced", "/profile light"} {
if !c.managementNotice(cmd) {
t.Fatalf("not handled: %s", cmd)
}
}
for _, notice := range notices {
if !strings.Contains(notice, "Execution modes have been retired.") {
t.Fatalf("notice: %s", notice)
}
}
if got := c.AgentPreset(); got != before {
t.Fatalf("retired command changed preset from %s to %s", before, got)
}
}
+32
View File
@@ -0,0 +1,32 @@
package control
import (
"fmt"
"strings"
"reasonix/internal/base/i18n"
"reasonix/internal/contract/config"
)
func remoteHostsText() string {
cfg, err := config.Load()
if err != nil {
return err.Error()
}
if len(cfg.Remote.Hosts) == 0 {
return i18n.M.RemoteNoHostsHint
}
var b strings.Builder
for _, h := range cfg.Remote.Hosts {
target := h.Host
if h.User != "" {
target = h.User + "@" + target
}
if h.Port != 0 && h.Port != 22 {
target = fmt.Sprintf("%s:%d", target, h.Port)
}
fmt.Fprintf(&b, " · %s %s\n", h.Name, target)
}
b.WriteString(" run `reasonix remote connect <name>` in a terminal to open the remote workspace")
return b.String()
}
+4
View File
@@ -433,6 +433,10 @@ func (c *Controller) managementNotice(trimmed string) bool {
return false
}
switch fields[0] {
case "/remote":
c.notice(remoteHostsText())
case "/preset", "/work-mode", "/profile":
c.notice("Execution modes have been retired. Recognized legacy values are accepted for compatibility and use standard execution.")
case "/model":
c.notice(c.modelListText())
case "/provider":
@@ -36,6 +36,7 @@ func SubmitSlashCommands(m i18n.Messages) []SlashItem {
{Label: "/hooks", Insert: "/hooks ", Hint: m.CmdHooks, Descend: true},
{Label: "/feedback", Insert: "/feedback ", Hint: m.CmdFeedback, Descend: true},
{Label: "/mcp", Insert: "/mcp ", Hint: m.CmdMcp, Descend: true},
{Label: "/remote", Insert: "/remote", Hint: m.CmdRemote},
{Label: "/docs", Insert: "/docs ", Hint: m.CmdDocs},
{Label: "/migrate", Insert: "/migrate", Hint: m.CmdMigrate},
{Label: "/reload-cmd", Insert: "/reload-cmd", Hint: m.CmdReloadCmd},