mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
573d660e1e | ||
|
|
183264fa10 |
No files matched your search
+1
-1
@@ -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
@@ -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 会话;已经运行的会话继续使用启动时加载的阈值。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 服务器
|
||||
|
||||
@@ -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 伺服器
|
||||
|
||||
@@ -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]
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
Reference in new issue
Block a user