From 2fceff0d8fd81cd8e40802b9e738d33dbd60e75d Mon Sep 17 00:00:00 2001 From: SIN CI Date: Thu, 18 Jun 2026 00:15:39 +0200 Subject: [PATCH] feat(skills): required_tools on all 37 skills, skill-code-create teaches required_tools, TUI WIP integrated, doc consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add required_tools frontmatter to 28 remaining skills (9 already had it) - All 37 bundled skills now declare their SIN tool dependencies - skill-code-create: templates/standards/workflow/prompt/output updated to teach required_tools field to agents scaffolding new skills - skill-code-graph: lifecycle moved to top-level frontmatter (was nested) - TUI WIP files integrated: model_switcher, help_overlay, file_picker, mouse, render_cache, theme_custom, crash_recovery — build passes clean - skilldist.doc.md: 8 → 11 targets in 4 places - docs/release-notes/v3.20.0.md: 8 → 11 agent families - Validator: 37/37 skills pass --strict, 0 failed - Build: go build ./cmd/sin-code/... passes clean (including TUI) - Tests: skillmgr, skilldist, profile, loopbuilder all pass -race -count=1 --- .../internal/skilldist/skilldist.doc.md | 8 +- cmd/sin-code/tui/accessibility.go | 298 ++++++++++++ cmd/sin-code/tui/chat_input.go | 31 +- cmd/sin-code/tui/crash_recovery.go | 101 ++++ cmd/sin-code/tui/dogfood_test.go | 436 ++++++++++++++++++ cmd/sin-code/tui/file_picker.go | 111 +++++ cmd/sin-code/tui/help_overlay.go | 235 ++++++++++ cmd/sin-code/tui/keymap.go | 6 +- cmd/sin-code/tui/model.go | 29 +- cmd/sin-code/tui/model_switcher.go | 191 ++++++++ cmd/sin-code/tui/mouse.go | 117 +++++ cmd/sin-code/tui/plugin.go | 205 ++++++++ cmd/sin-code/tui/plugin_test.go | 325 +++++++++++++ cmd/sin-code/tui/plugins/wordcount_plugin.go | 136 ++++++ cmd/sin-code/tui/render_cache.go | 52 +++ cmd/sin-code/tui/resize_test.go | 190 ++++++++ cmd/sin-code/tui/theme_custom.go | 150 ++++++ cmd/sin-code/tui/update.go | 47 +- docs/release-notes/v3.20.0.md | 2 +- .../skill-browser-tools/SKILL.md | 3 + skills/code-skills/skill-code-codocs/SKILL.md | 3 + skills/code-skills/skill-code-create/SKILL.md | 16 + .../skill-code-create/frameworks/standards.md | 7 +- .../skill-code-create/tasks/workflow.md | 3 + .../skill-code-create/templates/output.md | 1 + .../skill-code-create/templates/prompt.md | 1 + skills/code-skills/skill-code-docs/SKILL.md | 3 + skills/code-skills/skill-code-graph/SKILL.md | 4 +- .../skill-code-mcp-builder/SKILL.md | 3 + .../skill-design-frontend/SKILL.md | 3 + .../design-skills/skill-design-image/SKILL.md | 2 + .../skill-ecosystem-context/SKILL.md | 3 + .../skill-ecosystem-marketplace/SKILL.md | 2 + .../skill-github-account/SKILL.md | 3 + .../skill-github-actions/SKILL.md | 3 + .../github-skills/skill-github-app/SKILL.md | 3 + .../skill-github-governance/SKILL.md | 3 + .../skill-github-readme/SKILL.md | 3 + .../skill-infrastructure-cloudflare/SKILL.md | 3 + .../skill-infrastructure-oci-vm/SKILL.md | 2 + .../skill-infrastructure-supabase/SKILL.md | 3 + .../skill-memory-honcho-rollback/SKILL.md | 2 + .../skill-memory-honcho/SKILL.md | 3 + .../skill-memory-infisical/SKILL.md | 3 + .../skill-planning-enterprise/SKILL.md | 3 + .../process-skills/skill-code-lazy/SKILL.md | 3 + .../skill-process-goal/SKILL.md | 3 + .../skill-process-grill/SKILL.md | 3 + .../skill-process-scheduler/SKILL.md | 2 + .../skill-shop-cj-dropshipping/SKILL.md | 3 + skills/shop-skills/skill-shop-stripe/SKILL.md | 3 + skills/shop-skills/skill-shop-tiktok/SKILL.md | 3 + 52 files changed, 2759 insertions(+), 19 deletions(-) create mode 100644 cmd/sin-code/tui/accessibility.go create mode 100644 cmd/sin-code/tui/crash_recovery.go create mode 100644 cmd/sin-code/tui/dogfood_test.go create mode 100644 cmd/sin-code/tui/file_picker.go create mode 100644 cmd/sin-code/tui/help_overlay.go create mode 100644 cmd/sin-code/tui/model_switcher.go create mode 100644 cmd/sin-code/tui/mouse.go create mode 100644 cmd/sin-code/tui/plugin.go create mode 100644 cmd/sin-code/tui/plugin_test.go create mode 100644 cmd/sin-code/tui/plugins/wordcount_plugin.go create mode 100644 cmd/sin-code/tui/render_cache.go create mode 100644 cmd/sin-code/tui/resize_test.go create mode 100644 cmd/sin-code/tui/theme_custom.go diff --git a/cmd/sin-code/internal/skilldist/skilldist.doc.md b/cmd/sin-code/internal/skilldist/skilldist.doc.md index 7f87eedd..76ac821a 100644 --- a/cmd/sin-code/internal/skilldist/skilldist.doc.md +++ b/cmd/sin-code/internal/skilldist/skilldist.doc.md @@ -54,10 +54,10 @@ without the padding still parses correctly. | `rule` | `/>` (single file, .md or .mdc) | writes RenderBlock; replaces prior block for same skill | | `marker` | `/` (single shared file, no `` placeholder) | appends RenderBlock; preserves other skills' blocks | -## Registered targets (8, alphabetical) +## Registered targets (11, alphabetical) Format `dir`: `claude-code`, `gemini`, `opencode`. -Format `rule`: `cline`, `codex`, `cursor`, `windsurf`. +Format `rule`: `aider`, `cline`, `codex`, `continue`, `cursor`, `windsurf`, `zed`. Format `marker`: `copilot`. The `sin-code skill install --agent all` command runs through every @@ -89,7 +89,7 @@ can diff log output across machines. into `~/.claude/skills/` or `~/.agents/skills/`. Neither path shipped a `--agent` flag. Issue #169 fills the gap: take a bundled -Skill and route it to one of 8 agent families with a marker-fenced block so +Skill and route it to one of 11 agent families with a marker-fenced block so the install is idempotent across re-runs. The marker-fence guarantees no duplicate blocks grow on the disk even after dozens of updates. @@ -111,3 +111,5 @@ duplicate blocks grow on the disk even after dozens of updates. - **v1.0.0 / 2026-06-16** — initial implementation (issue #169). 8 targets, 3 formats, 1 marker convention. Race-safe in every code path. +- **v1.1.0 / 2026-06-17** — expanded to 11 targets: `aider`, `continue`, `zed` + added (rule format). 3 formats unchanged. diff --git a/cmd/sin-code/tui/accessibility.go b/cmd/sin-code/tui/accessibility.go new file mode 100644 index 00000000..457c85ca --- /dev/null +++ b/cmd/sin-code/tui/accessibility.go @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: MIT +package tui + +import ( + "fmt" + "strings" + "sync" + "time" +) + +type AccessibilityMode struct { + mu sync.RWMutex + highContrast bool + screenReader bool + keyboardOnly bool + largeText bool + reducedMotion bool +} + +func NewAccessibilityMode() *AccessibilityMode { + return &AccessibilityMode{} +} + +func (a *AccessibilityMode) HighContrast() bool { + a.mu.RLock() + defer a.mu.RUnlock() + return a.highContrast +} + +func (a *AccessibilityMode) SetHighContrast(b bool) { + a.mu.Lock() + a.highContrast = b + a.mu.Unlock() +} + +func (a *AccessibilityMode) ScreenReader() bool { + a.mu.RLock() + defer a.mu.RUnlock() + return a.screenReader +} + +func (a *AccessibilityMode) SetScreenReader(b bool) { + a.mu.Lock() + a.screenReader = b + a.mu.Unlock() +} + +func (a *AccessibilityMode) KeyboardOnlyMode() bool { + a.mu.RLock() + defer a.mu.RUnlock() + return a.keyboardOnly +} + +func (a *AccessibilityMode) SetKeyboardOnlyMode(b bool) { + a.mu.Lock() + a.keyboardOnly = b + a.mu.Unlock() +} + +func (a *AccessibilityMode) LargeText() bool { + a.mu.RLock() + defer a.mu.RUnlock() + return a.largeText +} + +func (a *AccessibilityMode) SetLargeText(b bool) { + a.mu.Lock() + a.largeText = b + a.mu.Unlock() +} + +func (a *AccessibilityMode) ReducedMotion() bool { + a.mu.RLock() + defer a.mu.RUnlock() + return a.reducedMotion +} + +func (a *AccessibilityMode) SetReducedMotion(b bool) { + a.mu.Lock() + a.reducedMotion = b + a.mu.Unlock() +} + +func (a *AccessibilityMode) ApplyToConfig(cfg map[string]bool) { + if cfg == nil { + return + } + if v, ok := cfg["high_contrast"]; ok { + a.SetHighContrast(v) + } + if v, ok := cfg["screen_reader"]; ok { + a.SetScreenReader(v) + } + if v, ok := cfg["reduced_motion"]; ok { + a.SetReducedMotion(v) + } + if v, ok := cfg["large_text"]; ok { + a.SetLargeText(v) + } +} + +func (a *AccessibilityMode) Describe(view ViewKind) string { + var parts []string + parts = append(parts, fmt.Sprintf("Current view: %s", view.String())) + switch view { + case ViewChat: + parts = append(parts, "Chat view. Type a message and press Ctrl+S to send. Use up and down arrows to scroll through history.") + case ViewTools: + parts = append(parts, "Tools view. Use up and down arrows to navigate the tool list. Press R to run a selected tool.") + case ViewSessions: + parts = append(parts, "Sessions view. Use up and down arrows to navigate sessions. Press plus to add, minus to close.") + case ViewTodos: + parts = append(parts, "Todos view. Use up and down arrows to navigate todo items.") + case ViewDAG: + parts = append(parts, "DAG view. Use up and down arrows to navigate tasks.") + case ViewLSP: + parts = append(parts, "LSP diagnostics view. Shows language server protocol diagnostics.") + default: + parts = append(parts, "Use Tab to switch views. Press question mark for help.") + } + if a.highContrast { + parts = append(parts, "High contrast mode is enabled.") + } + if a.reducedMotion { + parts = append(parts, "Reduced motion mode is enabled. Animations are disabled.") + } + if a.largeText { + parts = append(parts, "Large text mode is enabled.") + } + return strings.Join(parts, " ") +} + +func (a *AccessibilityMode) DescribeMessage(msg ChatMessage) string { + var role string + switch msg.Kind { + case chatUser: + role = "User message" + case chatAssistant: + role = "Assistant message" + case chatTool: + role = "Tool call" + case chatVerify: + role = "Verification" + case chatAsk: + role = "Permission request" + case chatDone: + role = "Task complete" + case chatError: + role = "Error" + case chatThinking: + role = "Thinking" + case chatSystem: + role = "System message" + case chatAgent: + role = "Agent message" + default: + role = "Message" + } + var parts []string + parts = append(parts, role) + if msg.Text != "" { + parts = append(parts, msg.Text) + } + if msg.Tool != "" { + parts = append(parts, fmt.Sprintf("Tool: %s", msg.Tool)) + } + if msg.Detail != "" { + parts = append(parts, msg.Detail) + } + if msg.ToolInput != "" { + parts = append(parts, fmt.Sprintf("Input: %s", msg.ToolInput)) + } + if msg.ToolOutput != "" { + output := msg.ToolOutput + if len(output) > 200 { + output = output[:200] + "..." + } + parts = append(parts, fmt.Sprintf("Output: %s", output)) + } + if msg.Result { + parts = append(parts, "Result: success") + } + if msg.Error != nil { + parts = append(parts, fmt.Sprintf("Error: %s", msg.Error.Error())) + } + if !msg.Timestamp.IsZero() { + parts = append(parts, fmt.Sprintf("Time: %s", msg.Timestamp.Format("15:04:05"))) + } + return strings.Join(parts, ". ") +} + +func (a *AccessibilityMode) AllKeyboardShortcuts() []HintPair { + return DefaultHints(ViewChat) +} + +func (a *AccessibilityMode) AllKeyboardShortcutsForView(view ViewKind) []HintPair { + hints := DefaultHints(view) + hints = append(hints, HintPair{Key: "?", Label: "help"}) + hints = append(hints, HintPair{Key: "ctrl+p", Label: "palette"}) + hints = append(hints, HintPair{Key: "ctrl+b", Label: "sidebar"}) + hints = append(hints, HintPair{Key: "q", Label: "quit"}) + return hints +} + +func (a *AccessibilityMode) StatusText(status string) string { + switch status { + case "running": + return "[RUNNING] " + case "passed": + return "[PASSED] " + case "failed": + return "[FAILED] " + case "idle": + return "[IDLE] " + case "pending": + return "[PENDING] " + default: + return "[" + strings.ToUpper(status) + "] " + } +} + +func (a *AccessibilityMode) SpinnerText(elapsed time.Duration) string { + if a.reducedMotion { + return fmt.Sprintf("thinking... (%ds)", int(elapsed.Seconds())) + } + return "" +} + +func (a *AccessibilityMode) ShouldAnimate() bool { + a.mu.RLock() + defer a.mu.RUnlock() + return !a.reducedMotion +} + +func (a *AccessibilityMode) ShouldShowMouseCursor() bool { + a.mu.RLock() + defer a.mu.RUnlock() + return !a.keyboardOnly +} + +func (a *AccessibilityMode) BoldAll() bool { + a.mu.RLock() + defer a.mu.RUnlock() + return a.largeText +} + +func (a *AccessibilityMode) ExtraPadding() int { + a.mu.RLock() + defer a.mu.RUnlock() + if a.largeText { + return 2 + } + return 0 +} + +func HighContrastTheme() Theme { + return Theme{ + Name: "HighContrast", + Accent: "#FFFF00", + AccentDim: "#FFD700", + Text: "#FFFFFF", + TextDim: "#CCCCCC", + Background: "#000000", + Border: "#FFFF00", + Success: "#00FF00", + Warn: "#FFFF00", + Error: "#FF0000", + } +} + +func (a *AccessibilityMode) ApplyTheme(base Theme) Theme { + a.mu.RLock() + defer a.mu.RUnlock() + if a.highContrast { + return HighContrastTheme() + } + return base +} + +func (a *AccessibilityMode) ApplyToStyles(base Styles) Styles { + a.mu.RLock() + hc := a.highContrast + lt := a.largeText + a.mu.RUnlock() + if hc { + return NewStyles(HighContrastTheme()) + } + if lt { + s := base + s.Content = s.Content.Bold(true) + s.Muted = s.Muted.Bold(true) + s.Footer = s.Footer.Bold(true) + s.FooterKey = s.FooterKey.Bold(true) + s.AccentText = s.AccentText.Bold(true) + return s + } + return base +} diff --git a/cmd/sin-code/tui/chat_input.go b/cmd/sin-code/tui/chat_input.go index 5dfb2e4a..0b4e38c5 100644 --- a/cmd/sin-code/tui/chat_input.go +++ b/cmd/sin-code/tui/chat_input.go @@ -110,7 +110,9 @@ func handleChatSubmit(m *Model, submit chat.SubmitMsg) tea.Cmd { " /attach-glob Attach files matching a glob pattern\n" + " /detach Remove an attachment\n" + " /clear Clear chat history\n" + - " /help Show this help message\n\n" + + " /help Show this help message\n" + + " /theme custom Load custom theme from JSON\n" + + " /theme export Export current theme to JSON\n\n" + "Keys:\n" + " Enter Send message\n" + " Shift+Enter Insert newline\n" + @@ -126,6 +128,33 @@ func handleChatSubmit(m *Model, submit chat.SubmitMsg) tea.Cmd { return nil } + if strings.HasPrefix(trimmed, "/theme custom ") { + path := strings.TrimSpace(strings.TrimPrefix(trimmed, "/theme custom ")) + if path == "" { + m.appendChat(ChatMessage{Kind: chatSystem, Text: "Usage: /theme custom "}) + return nil + } + if err := m.LoadCustomThemeFromPath(path); err != nil { + m.appendChat(ChatMessage{Kind: chatError, Text: "Failed to load theme: " + err.Error()}) + } else { + m.appendChat(ChatMessage{Kind: chatSystem, Text: "Custom theme loaded from " + path}) + } + return nil + } + if strings.HasPrefix(trimmed, "/theme export ") { + path := strings.TrimSpace(strings.TrimPrefix(trimmed, "/theme export ")) + if path == "" { + m.appendChat(ChatMessage{Kind: chatSystem, Text: "Usage: /theme export "}) + return nil + } + if err := m.ExportThemeToPath(path); err != nil { + m.appendChat(ChatMessage{Kind: chatError, Text: "Failed to export theme: " + err.Error()}) + } else { + m.appendChat(ChatMessage{Kind: chatSystem, Text: "Theme exported to " + path}) + } + return nil + } + if len(submit.Attachments) > 0 { entry += "\n[attachments:" for _, a := range submit.Attachments { diff --git a/cmd/sin-code/tui/crash_recovery.go b/cmd/sin-code/tui/crash_recovery.go new file mode 100644 index 00000000..e74c8b79 --- /dev/null +++ b/cmd/sin-code/tui/crash_recovery.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +package tui + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +type SessionState struct { + ViewKind int `json:"view_kind"` + ChatHistory []ChatMessage `json:"chat_history"` + SessionID string `json:"session_id"` + ModelName string `json:"model_name"` + ThemeIndex int `json:"theme_index"` + SidebarCollapsed bool `json:"sidebar_collapsed"` + SplitPaneActive bool `json:"split_pane_active"` + SavedAt time.Time `json:"saved_at"` +} + +type CrashRecovery struct { + path string +} + +func crashRecoveryPath() string { + home, _ := os.UserHomeDir() + dir := filepath.Join(home, ".local", "share", "sin-code") + _ = os.MkdirAll(dir, 0o755) + return filepath.Join(dir, "tui-state.json") +} + +func NewCrashRecovery() *CrashRecovery { + return &CrashRecovery{path: crashRecoveryPath()} +} + +func NewCrashRecoveryWithPath(path string) *CrashRecovery { + return &CrashRecovery{path: path} +} + +func (r *CrashRecovery) Path() string { return r.path } + +func (r *CrashRecovery) Save(state SessionState) error { + state.SavedAt = time.Now() + if len(state.ChatHistory) > 50 { + state.ChatHistory = state.ChatHistory[len(state.ChatHistory)-50:] + } + data, err := json.MarshalIndent(state, "", " ") + if err != nil { return err } + dir := filepath.Dir(r.path) + if err := os.MkdirAll(dir, 0o755); err != nil { return err } + tmpPath := r.path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0o644); err != nil { return err } + return os.Rename(tmpPath, r.path) +} + +func (r *CrashRecovery) Load() (*SessionState, error) { + data, err := os.ReadFile(r.path) + if err != nil { return nil, err } + var state SessionState + if err := json.Unmarshal(data, &state); err != nil { return nil, err } + return &state, nil +} + +func (r *CrashRecovery) Clear() error { + if !r.Exists() { return nil } + return os.Remove(r.path) +} + +func (r *CrashRecovery) Exists() bool { + _, err := os.Stat(r.path) + return err == nil +} + +func (m *Model) SaveCrashState() { + if m.CrashRecovery == nil { m.CrashRecovery = NewCrashRecovery() } + state := SessionState{ + ViewKind: int(m.ViewKind), ChatHistory: m.ChatHistory, + ModelName: m.Footer.ModelName, ThemeIndex: m.ThemeIdx, + SidebarCollapsed: m.Sidebar.Collapsed, + SplitPaneActive: m.SplitPane != nil && m.SplitPane.Active(), + } + _ = m.CrashRecovery.Save(state) +} + +func (m *Model) RestoreCrashState(state *SessionState) { + if state == nil { return } + if state.ViewKind >= 0 && state.ViewKind < viewCount { m.SwitchView(ViewKind(state.ViewKind)) } + if len(state.ChatHistory) > 0 { m.ChatHistory = state.ChatHistory } + if state.ModelName != "" { m.Footer.ModelName = state.ModelName; m.AgentConfig.Model = state.ModelName } + if state.ThemeIndex >= 0 && state.ThemeIndex < len(Themes) { m.ThemeIdx = state.ThemeIndex; m.ApplyTheme() } + if state.SidebarCollapsed != m.Sidebar.Collapsed { m.Sidebar.Toggle() } + if m.SplitPane != nil { m.SplitPane.SetActive(state.SplitPaneActive) } +} + +func RecoverAndSave(model *Model) { + if r := recover(); r != nil { + if model != nil { model.SaveCrashState() } + panic(r) + } +} diff --git a/cmd/sin-code/tui/dogfood_test.go b/cmd/sin-code/tui/dogfood_test.go new file mode 100644 index 00000000..13f9abf6 --- /dev/null +++ b/cmd/sin-code/tui/dogfood_test.go @@ -0,0 +1,436 @@ +//go:build integration + +// SPDX-License-Identifier: MIT +package tui + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/tui/chat" +) + +type stubLLM struct { + mu sync.Mutex + responses map[string]string + called int +} + +func newStubLLM() *stubLLM { + return &stubLLM{ + responses: map[string]string{ + "fix the bug": "I'll fix the bug by correcting the nil pointer dereference on line 10. The issue is that `result` is not checked for nil before accessing `result.Value`.", + "write tests": "I'll write comprehensive tests covering the main function and edge cases.", + "default": "I understand. Let me help you with that.", + }, + } +} + +func (s *stubLLM) run(ctx context.Context, prompt string, history []string) (string, int, error) { + s.mu.Lock() + s.called++ + s.mu.Unlock() + + lower := strings.ToLower(prompt) + for key, resp := range s.responses { + if key != "default" && strings.Contains(lower, key) { + return resp, len(resp), nil + } + } + return s.responses["default"], len(s.responses["default"]), nil +} + +func (s *stubLLM) runStream(ctx context.Context, prompt string, history []string, onChunk func(string)) (string, int, error) { + resp, tokens, err := s.run(ctx, prompt, history) + if err != nil { + return "", 0, err + } + words := strings.Fields(resp) + for _, w := range words { + onChunk(w + " ") + time.Sleep(2 * time.Millisecond) + } + return resp, tokens, nil +} + +func createTempProject(t *testing.T) string { + dir := t.TempDir() + mainGo := `package main + +import "fmt" + +func process(data []string) string { + result := compute(data) + return result.Value +} + +func compute(data []string) *Result { + if len(data) == 0 { + return nil + } + return &Result{Value: data[0]} +} + +type Result struct { + Value string +} + +func main() { + fmt.Println(process(nil)) +} +` + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(mainGo), 0o644); err != nil { + t.Fatal(err) + } + goMod := fmt.Sprintf("module testproject\n\ngo 1.23\n") + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +func setupDogfoodModel(t *testing.T, dir string) *Model { + stub := newStubLLM() + origRunHook := chatRunnerRunHook + origStreamHook := chatRunnerStreamHook + chatRunnerRunHook = func(r *chat.Runner, ctx context.Context, prompt string, history []string) (string, int, error) { + return stub.run(ctx, prompt, history) + } + chatRunnerStreamHook = func(r *chat.Runner, ctx context.Context, prompt string, history []string, onChunk func(string)) (string, int, error) { + return stub.runStream(ctx, prompt, history, onChunk) + } + t.Cleanup(func() { + chatRunnerRunHook = origRunHook + chatRunnerStreamHook = origStreamHook + }) + + m := NewModel() + m.Workspace = dir + m.initChatInput() + m.ViewKind = ViewChat + m.SwitchView(ViewChat) + m.Footer.SetView(ViewChat) + + return m +} + +func TestDogfoodFullSessionFlow(t *testing.T) { + dir := createTempProject(t) + m := setupDogfoodModel(t, dir) + + m.appendChat(ChatMessage{Kind: chatUser, Text: "Fix the bug in main.go"}) + m.AppendHistory(ViewChat.String(), "chat-submit", "Fix the bug in main.go", true) + + if len(m.ChatHistory) != 1 { + t.Fatalf("expected 1 chat message, got %d", len(m.ChatHistory)) + } + if m.ChatHistory[0].Kind != chatUser { + t.Errorf("expected chatUser kind, got %v", m.ChatHistory[0].Kind) + } + if !strings.Contains(m.ChatHistory[0].Text, "Fix the bug") { + t.Errorf("expected 'Fix the bug' in message, got %q", m.ChatHistory[0].Text) + } + + m.appendChat(ChatMessage{ + Kind: chatTool, + Tool: "sin_edit", + Result: true, + Detail: "main.go:7", + }) + m.appendChat(ChatMessage{ + Kind: chatAssistant, + Text: "I fixed the bug by adding a nil check before accessing result.Value.", + }) + + if len(m.ChatHistory) != 3 { + t.Fatalf("expected 3 messages, got %d", len(m.ChatHistory)) + } + + m.VerifyPanel.State = VerifyPassed + m.VerifyPanel.Mode = "poc" + m.VerifyPanel.Evidence = "go build ./... succeeded" + m.VerifyPanel.Target = "main.go" + m.appendChat(ChatMessage{Kind: chatVerify, Detail: "PASS: go build succeeded", Result: true}) + + if m.VerifyPanel.State != VerifyPassed { + t.Error("expected verify panel to show passed") + } + + verifyRendered := RenderVerifyPanel(m.VerifyPanel, m.Styles, 80) + if !strings.Contains(verifyRendered, "PASSED") { + t.Errorf("expected 'PASSED' in verify panel, got %q", verifyRendered) + } + + m.appendChat(ChatMessage{Kind: chatUser, Text: "Now write tests"}) + m.AppendHistory(ViewChat.String(), "chat-submit", "Now write tests", true) + m.appendChat(ChatMessage{ + Kind: chatTool, + Tool: "sin_test_generate", + Result: true, + Detail: "main_test.go", + }) + m.appendChat(ChatMessage{ + Kind: chatAssistant, + Text: "I've created comprehensive tests for the process and compute functions.", + }) + + m.VerifyPanel.State = VerifyPassed + m.VerifyPanel.Evidence = "go test ./... passed" + m.appendChat(ChatMessage{Kind: chatVerify, Detail: "PASS: go test succeeded", Result: true}) + + userCount := 0 + toolCount := 0 + verifyCount := 0 + assistantCount := 0 + for _, msg := range m.ChatHistory { + switch msg.Kind { + case chatUser: + userCount++ + case chatTool: + toolCount++ + case chatVerify: + verifyCount++ + case chatAssistant: + assistantCount++ + } + } + if userCount != 2 { + t.Errorf("expected 2 user messages, got %d", userCount) + } + if toolCount != 2 { + t.Errorf("expected 2 tool calls, got %d", toolCount) + } + if verifyCount != 2 { + t.Errorf("expected 2 verify messages, got %d", verifyCount) + } + if assistantCount != 2 { + t.Errorf("expected 2 assistant messages, got %d", assistantCount) + } + + historyCount := len(m.HistoryState.History) + if historyCount < 2 { + t.Errorf("expected at least 2 history entries, got %d", historyCount) + } +} + +func TestDogfoodToolCallRendering(t *testing.T) { + dir := createTempProject(t) + m := setupDogfoodModel(t, dir) + + m.appendChat(ChatMessage{Kind: chatUser, Text: "Fix the bug"}) + m.appendChat(ChatMessage{ + Kind: chatTool, + Tool: "sin_edit", + ToolInput: `{"path":"main.go","old":"return result.Value","new":"if result != nil { return result.Value } return """}`, + ToolOutput: "File edited successfully", + Result: true, + Detail: "main.go:7", + Expanded: true, + }) + + styles := m.Styles + rendered := renderToolCard(m.ChatHistory[1], styles, 80, true) + if !strings.Contains(rendered, "sin_edit") { + t.Errorf("expected 'sin_edit' in tool card, got %q", rendered) + } + + m.ChatHistory[1].Expanded = false + renderedCollapsed := renderToolCard(m.ChatHistory[1], styles, 80, false) + if !strings.Contains(renderedCollapsed, "sin_edit") { + t.Errorf("expected 'sin_edit' in collapsed tool card, got %q", renderedCollapsed) + } + + compactRendered := renderCompactMessage(m.ChatHistory[1], styles, 80, false, m.Spinner) + if !strings.Contains(compactRendered, "sin_edit") { + t.Errorf("expected 'sin_edit' in compact message, got %q", compactRendered) + } + + m.appendChat(ChatMessage{ + Kind: chatTool, + Tool: "sin_test_generate", + Result: false, + Detail: "generating tests...", + }) + rendered2 := renderToolCard(m.ChatHistory[2], styles, 80, false) + if !strings.Contains(rendered2, "sin_test_generate") { + t.Errorf("expected 'sin_test_generate' in tool card, got %q", rendered2) + } +} + +func TestDogfoodCompactCommand(t *testing.T) { + dir := createTempProject(t) + m := setupDogfoodModel(t, dir) + + for i := 0; i < 20; i++ { + m.appendChat(ChatMessage{ + Kind: chatAssistant, + Text: fmt.Sprintf("Response %d with some content that fills the context window", i), + }) + } + + if len(m.ChatHistory) != 20 { + t.Fatalf("expected 20 messages, got %d", len(m.ChatHistory)) + } + + if m.CompactMode == nil { + t.Fatal("expected compact mode to be initialized") + } + + m.CompactMode.Toggle() + if !m.CompactMode.Active() { + t.Error("expected compact mode to be active after toggle") + } + + styles := m.Styles + compactRendered := renderCompactMessage(m.ChatHistory[0], styles, 80, false, m.Spinner) + if compactRendered == "" { + t.Error("expected non-empty compact render") + } + + m.Footer.Compacted = true + footerRendered := m.Footer.Render(styles) + if !strings.Contains(footerRendered, "idle") { + t.Errorf("expected 'idle' in footer, got %q", footerRendered) + } + + m.CompactMode.Toggle() + if m.CompactMode.Active() { + t.Error("expected compact mode to be inactive after second toggle") + } + + m.ChatHistory = nil + m.appendChat(ChatMessage{Kind: chatUser, Text: "/clear"}) + clearHandled := handleChatSubmit(m, chat.SubmitMsg{Text: "/clear"}) + if clearHandled != nil { + t.Error("expected nil cmd from /clear") + } + if len(m.ChatHistory) != 0 { + t.Errorf("expected 0 messages after /clear, got %d", len(m.ChatHistory)) + } +} + +func TestDogfoodViewSwitching(t *testing.T) { + dir := createTempProject(t) + m := setupDogfoodModel(t, dir) + + if m.ViewKind != ViewChat { + t.Fatalf("expected initial view to be Chat, got %v", m.ViewKind) + } + + views := []ViewKind{ + ViewTools, ViewSessions, ViewEFM, ViewConfig, + ViewHistory, ViewTodos, ViewChat, ViewDAG, + ViewContextViz, ViewAgentDashboard, ViewLSP, + } + + for _, v := range views { + m.SwitchView(v) + if m.ViewKind != v { + t.Errorf("expected view %v, got %v", v, m.ViewKind) + } + if m.Footer.View() != v { + t.Errorf("expected footer view %v, got %v", v, m.Footer.View()) + } + } + + m.SwitchView(ViewChat) + for i := 0; i < viewCount; i++ { + m.NextView() + } + if m.ViewKind != ViewChat { + t.Errorf("expected wrap-around to Chat after %d nexts, got %v", viewCount, m.ViewKind) + } + + for i := 0; i < viewCount; i++ { + m.PrevView() + } + if m.ViewKind != ViewChat { + t.Errorf("expected wrap-around to Chat after %d prevs, got %v", viewCount, m.ViewKind) + } + + m.Sidebar.Toggle() + if !m.Sidebar.Collapsed { + t.Error("expected sidebar to be collapsed after toggle") + } + m.Sidebar.Toggle() + if m.Sidebar.Collapsed { + t.Error("expected sidebar to be visible after second toggle") + } + + styles := m.Styles + sidebarView := m.Sidebar.View(styles) + if sidebarView == "" { + t.Error("expected non-empty sidebar view") + } +} + +func TestDogfoodKeybindingStress(t *testing.T) { + dir := createTempProject(t) + m := setupDogfoodModel(t, dir) + + keys := []string{ + "tab", "shift+tab", "1", "2", "3", "4", "5", "6", "7", + "8", "9", "0", "ctrl+b", "ctrl+p", "esc", + "up", "down", "pgup", "pgdown", + "?", "q", + } + + for _, k := range keys { + _, _ = m.Update(tea.KeyPressMsg{Text: k}) + } + + for i := 0; i < 50; i++ { + m.NextView() + m.PrevView() + m.SwitchView(ViewChat) + } + + m.SwitchView(ViewChat) + m.initChatInput() + if m.ChatInput != nil { + m.ChatInput.SetValue("test message") + if m.ChatInput.RawValue() != "test message" { + t.Error("expected chat input to contain 'test message'") + } + m.ChatInput.Clear() + if m.ChatInput.RawValue() != "" { + t.Error("expected chat input to be cleared") + } + } + + m.SwitchView(ViewTools) + _ = m.View() + + themeCount := len(Themes) + for i := 0; i < themeCount+1; i++ { + m.CycleTheme() + } + if m.ThemeIdx < 0 || m.ThemeIdx >= themeCount { + t.Errorf("expected theme index in range [0,%d), got %d", themeCount, m.ThemeIdx) + } + + mainPath := filepath.Join(dir, "main.go") + m.ShowFilePreview(mainPath) + if m.FilePreview == "" { + t.Error("expected non-empty file preview after ShowFilePreview") + } + if !strings.Contains(m.FilePreview, "package main") { + t.Errorf("expected 'package main' in preview, got %q", m.FilePreview) + } + m.ClearFilePreview() + if m.FilePreview != "" { + t.Error("expected empty file preview after ClearFilePreview") + } + + _, err := os.Stat(filepath.Join(dir, "main.go")) + if err != nil { + t.Errorf("main.go should still exist: %v", err) + } +} diff --git a/cmd/sin-code/tui/file_picker.go b/cmd/sin-code/tui/file_picker.go new file mode 100644 index 00000000..721085b3 --- /dev/null +++ b/cmd/sin-code/tui/file_picker.go @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT +package tui + +import ( + "strings" + + tea "charm.land/bubbletea/v2" +) + +type FilePicker struct { + rootDir string + sel int + open bool + files []string +} + +func NewFilePicker(rootDir string) *FilePicker { + return &FilePicker{rootDir: rootDir} +} + +func (f *FilePicker) Open() { + f.open = true + f.sel = 0 +} + +func (f *FilePicker) Close() { + f.open = false +} + +func (f *FilePicker) IsOpen() bool { + return f.open +} + +func (f *FilePicker) Render(styles Styles, width, height int) string { + if !f.open { + return "" + } + var b strings.Builder + b.WriteString(styles.AccentText.Render(" Open File")) + b.WriteString("\n") + b.WriteString(styles.Muted.Render(strings.Repeat("-", width-6))) + b.WriteString("\n\n") + if len(f.files) == 0 { + b.WriteString(styles.Muted.Render(" No files found")) + b.WriteString("\n") + } else { + for i, file := range f.files { + label := " " + file + if i == f.sel { + b.WriteString(styles.PopupSel.Render(padRight(label, width-6))) + } else { + b.WriteString(styles.PopupItem.Render(padRight(label, width-6))) + } + b.WriteString("\n") + } + } + b.WriteString("\n") + b.WriteString(styles.Muted.Render(" up/down navigate | enter open | esc close")) + b.WriteString("\n") + return styles.Popup.Render(b.String()) +} + +func (f *FilePicker) MoveUp() { + if f.sel > 0 { + f.sel-- + } +} + +func (f *FilePicker) MoveDown() { + if f.sel < len(f.files)-1 { + f.sel++ + } +} + +func (m *Model) OpenFilePicker() { + if m.FilePicker == nil { + m.FilePicker = NewFilePicker(m.Workspace) + } + m.FilePicker.Open() + m.Mode = ModeFilePicker +} + +func (m *Model) handleFilePickerKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + switch key { + case "esc": + if m.FilePicker != nil { + m.FilePicker.Close() + } + m.Mode = ModeNormal + return m, nil + case "enter": + if m.FilePicker != nil && m.FilePicker.sel < len(m.FilePicker.files) { + m.ShowFilePreview(m.FilePicker.files[m.FilePicker.sel]) + m.FilePicker.Close() + } + m.Mode = ModeNormal + return m, nil + case "up": + if m.FilePicker != nil { + m.FilePicker.MoveUp() + } + return m, nil + case "down": + if m.FilePicker != nil { + m.FilePicker.MoveDown() + } + return m, nil + } + return m, nil +} diff --git a/cmd/sin-code/tui/help_overlay.go b/cmd/sin-code/tui/help_overlay.go new file mode 100644 index 00000000..9119ae67 --- /dev/null +++ b/cmd/sin-code/tui/help_overlay.go @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: MIT +package tui + +import ( + "fmt" + "sort" + "strings" + + "charm.land/lipgloss/v2" + tea "charm.land/bubbletea/v2" +) + +type helpEntry struct { + key string + action string +} + +type helpGroup struct { + name string + entries []helpEntry +} + +type HelpOverlay struct { + keymapCfg KeymapConfig + groups []helpGroup + active bool + scroll int +} + +func NewHelpOverlay(keymap KeymapConfig) *HelpOverlay { + ho := &HelpOverlay{keymapCfg: keymap} + ho.buildGroups() + return ho +} + +func (ho *HelpOverlay) buildGroups() { + ho.groups = []helpGroup{ + ho.buildGroup("Global", ho.keymapCfg.Global), + ho.buildGroup("Navigation", filterNavBindings(ho.keymapCfg.Global)), + ho.buildGroup("Tools", ho.keymapCfg.Tools), + ho.buildGroup("Chat", ho.keymapCfg.Chat), + ho.buildGroup("Sessions", ho.keymapCfg.Sessions), + } +} + +func (ho *HelpOverlay) buildGroup(name string, bindings ContextBindings) helpGroup { + g := helpGroup{name: name} + keys := make([]string, 0, len(bindings)) + for k := range bindings { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + g.entries = append(g.entries, helpEntry{ + key: formatKeys(bindings[k]), + action: formatActionLabel(k), + }) + } + return g +} + +func filterNavBindings(global ContextBindings) ContextBindings { + nav := ContextBindings{} + navActions := []string{"next_view", "prev_view", "view_tools", "view_sessions", + "view_efm", "view_config", "view_history", "view_todos", + "view_chat", "view_dag", "view_context", "view_dashboard"} + for _, a := range navActions { + if keys, ok := global[a]; ok { + nav[a] = keys + } + } + return nav +} + +func formatKeys(keys []string) string { + if len(keys) == 0 { + return "" + } + return strings.Join(keys, ", ") +} + +func formatActionLabel(action string) string { + labels := map[string]string{ + "quit": "Quit", + "help": "Help overlay", + "palette": "Command palette", + "toggle_sidebar": "Toggle sidebar", + "cycle_theme": "Cycle theme", + "cycle_agent": "Cycle agent", + "interrupt": "Interrupt / Esc", + "model_select": "Model selector", + "subagents": "Subagents popup", + "next_view": "Next view", + "prev_view": "Previous view", + "view_tools": "Jump to Tools", + "view_sessions": "Jump to Sessions", + "view_efm": "Jump to EFM", + "view_config": "Jump to Config", + "view_history": "Jump to History", + "view_todos": "Jump to Todos", + "view_chat": "Jump to Chat", + "view_dag": "Jump to DAG", + "view_context": "Jump to Context", + "view_dashboard": "Jump to Dashboard", + "run_tool": "Run tool", + "show_help": "Show help / expand", + "tool_up": "Move up", + "tool_down": "Move down", + "submit": "Submit message", + "cancel": "Cancel", + "search": "Search chat", + "copy_message": "Copy message", + "scroll_up": "Scroll up", + "scroll_down": "Scroll down", + "compact_toggle": "Toggle compact mode", + "new_session": "New session", + "close_session": "Close session", + "session_switch": "Switch session", + } + if label, ok := labels[action]; ok { + return label + } + return strings.ReplaceAll(action, "_", " ") +} + +func (ho *HelpOverlay) Open() { + ho.active = true + ho.scroll = 0 +} + +func (ho *HelpOverlay) Close() { + ho.active = false +} + +func (ho *HelpOverlay) IsActive() bool { + return ho.active +} + +func (ho *HelpOverlay) ScrollUp() { + if ho.scroll > 0 { + ho.scroll-- + } +} + +func (ho *HelpOverlay) ScrollDown(maxScroll int) { + if ho.scroll < maxScroll { + ho.scroll++ + } +} + +func (ho *HelpOverlay) Render(styles Styles, width, height int) string { + if !ho.active { + return "" + } + if width < 30 { + width = 30 + } + if height < 10 { + height = 10 + } + contentWidth := width - 4 + keyColWidth := 18 + actionColWidth := contentWidth - keyColWidth - 4 + if actionColWidth < 10 { + actionColWidth = 10 + } + var b strings.Builder + b.WriteString(styles.AccentText.Render(" Keybindings")) + b.WriteString("\n") + b.WriteString(styles.Muted.Render(strings.Repeat("-", contentWidth))) + b.WriteString("\n\n") + visibleLines := 0 + maxLines := height - 6 + skipped := 0 + for _, g := range ho.groups { + if visibleLines >= maxLines { + break + } + if skipped < ho.scroll { + skipped++ + continue + } + b.WriteString(styles.Header.Render(g.name)) + b.WriteString("\n") + for _, e := range g.entries { + if visibleLines >= maxLines { + break + } + keyStr := padRight(e.key, keyColWidth) + actionStr := truncateString(e.action, actionColWidth) + line := fmt.Sprintf(" %s %s", styles.AccentText.Render(keyStr), styles.Content.Render(actionStr)) + b.WriteString(line) + b.WriteString("\n") + visibleLines++ + } + b.WriteString("\n") + visibleLines++ + } + b.WriteString("\n") + b.WriteString(styles.Muted.Render(padRight("[Esc] close", contentWidth))) + b.WriteString("\n") + overlayStyle := lipgloss.NewStyle(). + Foreground(c(styles.Theme.Text)). + Background(c(styles.Theme.Background)). + Width(width). + Height(height). + Padding(1, 2) + return overlayStyle.Render(b.String()) +} + +func (ho *HelpOverlay) HandleKey(msg tea.KeyMsg) bool { + key := msg.String() + switch key { + case "esc", "?", "q": + ho.Close() + return true + case "up", "k": + ho.ScrollUp() + return true + case "down", "j": + ho.ScrollDown(100) + return true + } + return false +} + +func (m *Model) handleHelpOverlayKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.HelpOverlay != nil { + m.HelpOverlay.HandleKey(msg) + } + if m.HelpOverlay == nil || !m.HelpOverlay.IsActive() { + m.Mode = ModeNormal + } + return m, nil +} diff --git a/cmd/sin-code/tui/keymap.go b/cmd/sin-code/tui/keymap.go index 0c97e5ea..810f70c0 100644 --- a/cmd/sin-code/tui/keymap.go +++ b/cmd/sin-code/tui/keymap.go @@ -256,14 +256,14 @@ func (k *Keymap) applyOverride(b *key.Binding, keys []string) { *b = key.NewBinding(key.WithKeys(keys...), key.WithHelp(help.Key, help.Desc)) } -type MouseAction struct { +type MouseResolution struct { Kind string X int Y int Target string } -func ResolveMouse(msg tea.MouseMsg, width, height int, sidebarWidth int, rightPanelWidth int) MouseAction { +func ResolveMouse(msg tea.MouseMsg, width, height int, sidebarWidth int, rightPanelWidth int) MouseResolution { var x, y int kind := "click" switch evt := msg.(type) { @@ -285,7 +285,7 @@ func ResolveMouse(msg tea.MouseMsg, width, height int, sidebarWidth int, rightPa x, y = m.X, m.Y kind = "click" } - return MouseAction{ + return MouseResolution{ Kind: kind, X: x, Y: y, diff --git a/cmd/sin-code/tui/model.go b/cmd/sin-code/tui/model.go index 40cc33eb..fdc6f967 100644 --- a/cmd/sin-code/tui/model.go +++ b/cmd/sin-code/tui/model.go @@ -27,8 +27,11 @@ const ( ModeArgInput ModeSessionSwitcher ModeModelSelector + ModeModelSwitcher ModePermissionDialog ModeSearch + ModeHelpOverlay + ModeFilePicker ) type PaletteState struct { @@ -253,6 +256,18 @@ type Model struct { // Compact rendering toggle (chat view) CompactMode *CompactMode + // Model switcher popup (#315) + ModelSwitcher *ModelSwitcher + + // Help overlay (#306) + HelpOverlay *HelpOverlay + + // File picker popup (#304) + FilePicker *FilePicker + + // Crash recovery (#311) + CrashRecovery *CrashRecovery + // Session info bar (chat view) SessionInfo *SessionInfo @@ -260,6 +275,10 @@ type Model struct { SplitPane *SplitPane FileBrowser *FileBrowser FileViewer *FileViewer + + Mouse *MouseHandler + + RenderCache *RenderCache } func (m *Model) ctx() context.Context { @@ -408,12 +427,18 @@ func NewModel() *Model { MaxTurns: 20, VerifyMode: "poc", }, - TokenBar: NewTokenBar(128000), - CompactMode: NewCompactMode(), + TokenBar: NewTokenBar(128000), + CompactMode: NewCompactMode(), + ModelSwitcher: NewModelSwitcher(), + HelpOverlay: NewHelpOverlay(DefaultKeymapConfig()), + FilePicker: NewFilePicker(""), + CrashRecovery: NewCrashRecovery(), SessionInfo: NewSessionInfo(), SplitPane: NewSplitPane(), FileBrowser: NewFileBrowser(""), FileViewer: NewFileViewer(), + Mouse: NewMouseHandler(), + RenderCache: NewRenderCache(100), } m.Footer.SetView(ViewChat) m.Footer.ShowHints = false diff --git a/cmd/sin-code/tui/model_switcher.go b/cmd/sin-code/tui/model_switcher.go new file mode 100644 index 00000000..157e4c2d --- /dev/null +++ b/cmd/sin-code/tui/model_switcher.go @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: MIT +package tui + +import ( + "fmt" + "strings" + "sync" + + tea "charm.land/bubbletea/v2" +) + +type ModelSwitcher struct { + mu sync.Mutex + open bool + models []string + sel int + current string + selected string +} + +func NewModelSwitcher() *ModelSwitcher { + return &ModelSwitcher{} +} + +func (s *ModelSwitcher) SetModels(models []string) { + s.mu.Lock() + defer s.mu.Unlock() + s.models = models + if s.sel >= len(s.models) { + s.sel = 0 + } +} + +func (s *ModelSwitcher) SetCurrent(model string) { + s.mu.Lock() + defer s.mu.Unlock() + s.current = model +} + +func (s *ModelSwitcher) Open() { + s.mu.Lock() + defer s.mu.Unlock() + s.open = true + s.sel = 0 + s.selected = "" +} + +func (s *ModelSwitcher) Close() { + s.mu.Lock() + defer s.mu.Unlock() + s.open = false +} + +func (s *ModelSwitcher) IsOpen() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.open +} + +func (s *ModelSwitcher) MoveUp() { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.models) == 0 { + return + } + s.sel-- + if s.sel < 0 { + s.sel = len(s.models) - 1 + } +} + +func (s *ModelSwitcher) MoveDown() { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.models) == 0 { + return + } + s.sel++ + if s.sel >= len(s.models) { + s.sel = 0 + } +} + +func (s *ModelSwitcher) Confirm() string { + s.mu.Lock() + defer s.mu.Unlock() + if s.sel < 0 || s.sel >= len(s.models) { + return "" + } + s.selected = s.models[s.sel] + s.open = false + return s.selected +} + +func (s *ModelSwitcher) Render(styles Styles, width, height int) string { + s.mu.Lock() + defer s.mu.Unlock() + if !s.open { + return "" + } + popupWidth := 50 + if popupWidth > width-4 { + popupWidth = width - 4 + } + if popupWidth < 30 { + popupWidth = 30 + } + var b strings.Builder + b.WriteString(styles.AccentText.Render(" Switch Model")) + b.WriteString("\n") + b.WriteString(styles.Muted.Render(strings.Repeat("-", popupWidth-4))) + b.WriteString("\n\n") + if len(s.models) == 0 { + b.WriteString(styles.Muted.Render(" No models available")) + b.WriteString("\n") + } else { + for i, model := range s.models { + marker := " " + if model == s.current { + marker = "+" + } + label := fmt.Sprintf(" %s %s", marker, model) + if i == s.sel { + b.WriteString(styles.PopupSel.Render(padRight(label, popupWidth-4))) + } else { + b.WriteString(styles.PopupItem.Render(padRight(label, popupWidth-4))) + } + b.WriteString("\n") + } + } + b.WriteString("\n") + b.WriteString(styles.Muted.Render(" up/down navigate | enter select | esc close")) + b.WriteString("\n") + return styles.Popup.Render(b.String()) +} + +type ModelSwitchMsg struct { + Model string +} + +func (m *Model) OpenModelSwitcher() { + if m.ModelSwitcher == nil { + m.ModelSwitcher = NewModelSwitcher() + } + m.ModelSwitcher.SetModels(switcherModelNames()) + m.ModelSwitcher.SetCurrent(m.Footer.ModelName) + m.ModelSwitcher.Open() + m.Mode = ModeModelSwitcher +} + +func (m *Model) CloseModelSwitcher() { + if m.ModelSwitcher != nil { + m.ModelSwitcher.Close() + } + m.Mode = ModeNormal +} + +func (m *Model) handleModelSwitcherKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + switch key { + case "esc": + m.CloseModelSwitcher() + return m, nil + case "enter": + selected := m.ModelSwitcher.Confirm() + if selected != "" { + m.Footer.ModelName = selected + m.AgentConfig.Model = selected + m.appendChat(ChatMessage{Kind: chatSystem, Text: fmt.Sprintf("[switched to %s]", selected)}) + m.AppendHistory(ViewChat.String(), "model-switch", selected, true) + m.Mode = ModeNormal + return m, func() tea.Msg { return ModelSwitchMsg{Model: selected} } + } + return m, nil + case "up": + m.ModelSwitcher.MoveUp() + return m, nil + case "down": + m.ModelSwitcher.MoveDown() + return m, nil + } + return m, nil +} + +func switcherModelNames() []string { + out := make([]string, 0, len(DefaultModels)) + for _, mi := range DefaultModels { + out = append(out, mi.Name) + } + return out +} diff --git a/cmd/sin-code/tui/mouse.go b/cmd/sin-code/tui/mouse.go new file mode 100644 index 00000000..4c56a768 --- /dev/null +++ b/cmd/sin-code/tui/mouse.go @@ -0,0 +1,117 @@ +package tui + +import ( + "sync" + tea "charm.land/bubbletea/v2" +) + +type MouseAction int + +const ( + MouseNone MouseAction = iota + MouseSelectItem + MouseScrollUp + MouseScrollDown + MouseClickSidebar + MouseClickInput + MouseResize +) + +func (a MouseAction) String() string { + switch a { + case MouseNone: return "none" + case MouseSelectItem: return "select_item" + case MouseScrollUp: return "scroll_up" + case MouseScrollDown: return "scroll_down" + case MouseClickSidebar: return "click_sidebar" + case MouseClickInput: return "click_input" + case MouseResize: return "resize" + } + return "unknown" +} + +type MouseHandler struct { + mu sync.RWMutex + enabled bool + width int + height int + sidebarW int + rightW int +} + +func NewMouseHandler() *MouseHandler { + return &MouseHandler{enabled: true, width: 80, height: 24, sidebarW: 22} +} + +func (h *MouseHandler) SetLayout(width, height, sidebarWidth, rightPanelWidth int) { + h.mu.Lock(); h.width = width; h.height = height; h.sidebarW = sidebarWidth; h.rightW = rightPanelWidth; h.mu.Unlock() +} + +func (h *MouseHandler) HandleClick(x, y int, view ViewKind) MouseAction { + h.mu.RLock(); defer h.mu.RUnlock() + if !h.enabled { return MouseNone } + const tabBarHeight = 3; const footerHeight = 3 + if y < 0 || x < 0 { return MouseNone } + if y < tabBarHeight { return MouseSelectItem } + if h.height > tabBarHeight+footerHeight && y >= h.height-footerHeight { return MouseNone } + if view == ViewChat { + if h.sidebarW > 0 && x < h.sidebarW { return MouseClickSidebar } + inputAreaStart := h.height - footerHeight - 6 + if inputAreaStart < tabBarHeight { inputAreaStart = tabBarHeight } + if y >= inputAreaStart { return MouseClickInput } + return MouseSelectItem + } + if h.sidebarW > 0 && x < h.sidebarW { return MouseClickSidebar } + return MouseSelectItem +} + +func (h *MouseHandler) HandleWheel(deltaY int) MouseAction { + h.mu.RLock(); defer h.mu.RUnlock() + if !h.enabled { return MouseNone } + if deltaY < 0 { return MouseScrollUp } + if deltaY > 0 { return MouseScrollDown } + return MouseNone +} + +func (h *MouseHandler) HandleMotion(x, y int, view ViewKind) MouseAction { h.mu.RLock(); defer h.mu.RUnlock(); return MouseNone } +func (h *MouseHandler) Enabled() bool { h.mu.RLock(); defer h.mu.RUnlock(); return h.enabled } +func (h *MouseHandler) Enable() { h.mu.Lock(); h.enabled = true; h.mu.Unlock() } +func (h *MouseHandler) Disable() { h.mu.Lock(); h.enabled = false; h.mu.Unlock() } + +func (m *Model) handleMouseClickMsg(msg tea.MouseClickMsg) (tea.Model, tea.Cmd) { + if m.Mouse != nil { + m.Mouse.SetLayout(m.Width, m.Height, m.Sidebar.Width, m.rightWidth()) + action := m.Mouse.HandleClick(msg.X, msg.Y, m.ViewKind) + switch action { + case MouseClickSidebar: + return m, m.handleSidebarClick(MouseResolution{Kind: "click", X: msg.X, Y: msg.Y, Target: "sidebar"}) + case MouseClickInput: + if m.ChatInput != nil { return m, m.ChatInput.Focus() } + return m, nil + case MouseSelectItem: + res := ResolveMouse(msg, m.Width, m.Height, m.Sidebar.Width, m.rightWidth()) + return m, m.handleMouseClick(res) + } + } + res := ResolveMouse(msg, m.Width, m.Height, m.Sidebar.Width, m.rightWidth()) + return m, m.handleMouseAction(res) +} + +func (m *Model) handleMouseWheelMsg(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { + if m.Mouse != nil { + var deltaY int + switch msg.Button { case tea.MouseWheelUp: deltaY = -1; case tea.MouseWheelDown: deltaY = 1 } + action := m.Mouse.HandleWheel(deltaY) + switch action { + case MouseScrollUp: return m, m.handleMouseScrollUp(MouseResolution{Kind: "scroll_up", X: msg.X, Y: msg.Y}) + case MouseScrollDown: return m, m.handleMouseScrollDown(MouseResolution{Kind: "scroll_down", X: msg.X, Y: msg.Y}) + } + } + res := ResolveMouse(msg, m.Width, m.Height, m.Sidebar.Width, m.rightWidth()) + return m, m.handleMouseAction(res) +} + +func (m *Model) handleMouseMotionMsg(msg tea.MouseMotionMsg) (tea.Model, tea.Cmd) { + if m.Mouse != nil { _ = m.Mouse.HandleMotion(msg.X, msg.Y, m.ViewKind) } + return m, nil +} diff --git a/cmd/sin-code/tui/plugin.go b/cmd/sin-code/tui/plugin.go new file mode 100644 index 00000000..2f05b5b7 --- /dev/null +++ b/cmd/sin-code/tui/plugin.go @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: MIT +package tui + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + + tea "charm.land/bubbletea/v2" +) + +type TUIPlugin interface { + Name() string + Render(styles Styles, width, height int) string + Update(msg tea.Msg) (handled bool) + Keybindings() []HintPair + SidebarItem() SidebarItem +} + +type PluginManager struct { + mu sync.RWMutex + plugins map[string]TUIPlugin + order []string +} + +func NewPluginManager() *PluginManager { + return &PluginManager{plugins: make(map[string]TUIPlugin)} +} + +func (pm *PluginManager) Register(plugin TUIPlugin) { + pm.mu.Lock() + defer pm.mu.Unlock() + name := plugin.Name() + if _, exists := pm.plugins[name]; !exists { + pm.order = append(pm.order, name) + } + pm.plugins[name] = plugin +} + +func (pm *PluginManager) Unregister(name string) { + pm.mu.Lock() + defer pm.mu.Unlock() + delete(pm.plugins, name) + for i, n := range pm.order { + if n == name { + pm.order = append(pm.order[:i], pm.order[i+1:]...) + break + } + } +} + +func (pm *PluginManager) Get(name string) (TUIPlugin, bool) { + pm.mu.RLock() + defer pm.mu.RUnlock() + p, ok := pm.plugins[name] + return p, ok +} + +func (pm *PluginManager) All() []TUIPlugin { + pm.mu.RLock() + defer pm.mu.RUnlock() + result := make([]TUIPlugin, 0, len(pm.order)) + for _, name := range pm.order { + if p, ok := pm.plugins[name]; ok { + result = append(result, p) + } + } + return result +} + +func (pm *PluginManager) Count() int { + pm.mu.RLock() + defer pm.mu.RUnlock() + return len(pm.plugins) +} + +type PluginManifest struct { + Name string `json:"name"` + Description string `json:"description"` + Builtin string `json:"builtin"` + Config map[string]string `json:"config,omitempty"` +} + +func (pm *PluginManager) LoadFromDir(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("read plugin dir %q: %w", dir, err) + } + var manifests []PluginManifest + for _, entry := range entries { + if !entry.IsDir() { + continue + } + manifestPath := filepath.Join(dir, entry.Name(), "plugin.json") + data, err := os.ReadFile(manifestPath) + if err != nil { + continue + } + var m PluginManifest + if err := json.Unmarshal(data, &m); err != nil { + continue + } + if m.Name == "" { + m.Name = entry.Name() + } + manifests = append(manifests, m) + } + sort.Slice(manifests, func(i, j int) bool { return manifests[i].Name < manifests[j].Name }) + for _, m := range manifests { + if m.Builtin == "" { + continue + } + p := pm.createBuiltin(m) + if p != nil { + if m.Name != p.Name() { + p = &namedPlugin{inner: p, name: m.Name} + } + pm.Register(p) + } + } + return nil +} + +type namedPlugin struct { + inner TUIPlugin + name string +} + +func (n *namedPlugin) Name() string { return n.name } +func (n *namedPlugin) Render(s Styles, w, h int) string { return n.inner.Render(s, w, h) } +func (n *namedPlugin) Update(msg tea.Msg) (handled bool) { return n.inner.Update(msg) } +func (n *namedPlugin) Keybindings() []HintPair { return n.inner.Keybindings() } +func (n *namedPlugin) SidebarItem() SidebarItem { return n.inner.SidebarItem() } + +var ( + builtinFactories = make(map[string]func(map[string]string) TUIPlugin) + builtinMu sync.RWMutex +) + +func RegisterBuiltin(name string, factory func(map[string]string) TUIPlugin) { + builtinMu.Lock() + defer builtinMu.Unlock() + builtinFactories[name] = factory +} + +func getBuiltinFactory(name string) func(map[string]string) TUIPlugin { + builtinMu.RLock() + defer builtinMu.RUnlock() + return builtinFactories[name] +} + +func (pm *PluginManager) createBuiltin(m PluginManifest) TUIPlugin { + factory := getBuiltinFactory(m.Builtin) + if factory == nil { + return nil + } + return factory(m.Config) +} + +func (pm *PluginManager) RenderAll(styles Styles, width, height int) string { + plugins := pm.All() + if len(plugins) == 0 { + return "" + } + perHeight := height / len(plugins) + if perHeight < 3 { + perHeight = 3 + } + var b []byte + for _, p := range plugins { + rendered := p.Render(styles, width, perHeight) + b = append(b, rendered...) + b = append(b, '\n') + } + return string(b) +} + +func (pm *PluginManager) UpdateAll(msg tea.Msg) { + pm.mu.RLock() + defer pm.mu.RUnlock() + for _, p := range pm.plugins { + p.Update(msg) + } +} + +func (pm *PluginManager) AllSidebarItems() []SidebarItem { + plugins := pm.All() + items := make([]SidebarItem, 0, len(plugins)) + for _, p := range plugins { + items = append(items, p.SidebarItem()) + } + return items +} + +func (pm *PluginManager) AllKeybindings() []HintPair { + plugins := pm.All() + var hints []HintPair + for _, p := range plugins { + hints = append(hints, p.Keybindings()...) + } + return hints +} diff --git a/cmd/sin-code/tui/plugin_test.go b/cmd/sin-code/tui/plugin_test.go new file mode 100644 index 00000000..9bb216ff --- /dev/null +++ b/cmd/sin-code/tui/plugin_test.go @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: MIT +package tui + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + tea "charm.land/bubbletea/v2" +) + +type mockPlugin struct { + name string + renderOut string + handled bool + hints []HintPair + sidebar SidebarItem +} + +func (m *mockPlugin) Name() string { return m.name } +func (m *mockPlugin) Render(styles Styles, width, height int) string { return m.renderOut } +func (m *mockPlugin) Update(msg tea.Msg) (handled bool) { return m.handled } +func (m *mockPlugin) Keybindings() []HintPair { return m.hints } +func (m *mockPlugin) SidebarItem() SidebarItem { return m.sidebar } + +func TestPluginRegisterUnregister(t *testing.T) { + pm := NewPluginManager() + p1 := &mockPlugin{name: "alpha", renderOut: "alpha-panel"} + p2 := &mockPlugin{name: "beta", renderOut: "beta-panel"} + pm.Register(p1) + if pm.Count() != 1 { t.Fatalf("expected 1 plugin, got %d", pm.Count()) } + pm.Register(p2) + if pm.Count() != 2 { t.Fatalf("expected 2 plugins, got %d", pm.Count()) } + pm.Unregister("alpha") + if pm.Count() != 1 { t.Fatalf("expected 1 after unregister, got %d", pm.Count()) } + if _, ok := pm.Get("alpha"); ok { t.Error("alpha should be gone") } + pm.Unregister("nonexistent") + pm.Unregister("beta") + if pm.Count() != 0 { t.Fatalf("expected 0, got %d", pm.Count()) } +} + +func TestPluginGet(t *testing.T) { + pm := NewPluginManager() + p := &mockPlugin{name: "gamma", renderOut: "gamma"} + pm.Register(p) + got, ok := pm.Get("gamma") + if !ok { t.Fatal("expected to find gamma") } + if got.Name() != "gamma" { t.Errorf("expected gamma, got %s", got.Name()) } + if _, ok := pm.Get("nonexistent"); ok { t.Error("should not find nonexistent") } +} + +func TestPluginAll(t *testing.T) { + pm := NewPluginManager() + pm.Register(&mockPlugin{name: "delta"}) + pm.Register(&mockPlugin{name: "epsilon"}) + all := pm.All() + if len(all) != 2 { t.Fatalf("expected 2, got %d", len(all)) } + if all[0].Name() != "delta" || all[1].Name() != "epsilon" { t.Errorf("order mismatch") } + pm.Register(&mockPlugin{name: "delta"}) + all = pm.All() + if len(all) != 2 { t.Errorf("re-register should not duplicate, got %d", len(all)) } +} + +func TestPluginRender(t *testing.T) { + pm := NewPluginManager() + styles := NewStyles(Themes[0]) + pm.Register(&mockPlugin{name: "zeta", renderOut: "zeta-content"}) + rendered := pm.RenderAll(styles, 80, 24) + if rendered == "" { t.Fatal("expected non-empty render") } + if !strings.Contains(rendered, "zeta-content") { t.Errorf("expected zeta-content, got %q", rendered) } + if NewPluginManager().RenderAll(styles, 80, 24) != "" { t.Error("empty manager should render empty") } +} + +func TestPluginUpdate(t *testing.T) { + pm := NewPluginManager() + pm.Register(&mockPlugin{name: "eta", handled: true}) + pm.UpdateAll(nil) +} + +func TestPluginKeybindings(t *testing.T) { + pm := NewPluginManager() + hints := []HintPair{{Key: "ctrl+r", Label: "refresh"}, {Key: "ctrl+w", Label: "close"}} + pm.Register(&mockPlugin{name: "theta", hints: hints}) + got := pm.AllKeybindings() + if len(got) != 2 { t.Fatalf("expected 2, got %d", len(got)) } + if got[0].Key != "ctrl+r" { t.Errorf("expected ctrl+r, got %s", got[0].Key) } +} + +func TestPluginSidebar(t *testing.T) { + pm := NewPluginManager() + item := SidebarItem{Icon: "W", Label: "Word Count", Shortcut: "w"} + pm.Register(&mockPlugin{name: "iota", sidebar: item}) + items := pm.AllSidebarItems() + if len(items) != 1 { t.Fatalf("expected 1, got %d", len(items)) } + if items[0].Label != "Word Count" { t.Errorf("expected Word Count, got %q", items[0].Label) } +} + +func TestPluginManifestLoad(t *testing.T) { + RegisterBuiltin("wordcount", func(config map[string]string) TUIPlugin { + return &mockPlugin{ + name: "wordcount", + renderOut: "wc-panel", + hints: []HintPair{{Key: "ctrl+r", Label: "refresh"}}, + sidebar: SidebarItem{Icon: "W", Label: "Word Count", Shortcut: "w"}, + } + }) + dir := t.TempDir() + pluginDir := filepath.Join(dir, "mywordcount") + os.MkdirAll(pluginDir, 0o755) + testFile := filepath.Join(dir, "test.txt") + os.WriteFile(testFile, []byte("hello world\ntest\n"), 0o644) + manifest := `{"name":"mywordcount","description":"wc","builtin":"wordcount","config":{"file":"` + testFile + `"}}` + os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0o644) + pm := NewPluginManager() + if err := pm.LoadFromDir(dir); err != nil { t.Fatalf("LoadFromDir: %v", err) } + if pm.Count() != 1 { t.Fatalf("expected 1, got %d", pm.Count()) } + p, ok := pm.Get("mywordcount") + if !ok { t.Fatal("expected mywordcount") } + if p.Name() != "mywordcount" { t.Errorf("expected mywordcount, got %s", p.Name()) } +} + +func TestPluginManifestLoadEmptyDir(t *testing.T) { + pm := NewPluginManager() + if err := pm.LoadFromDir(t.TempDir()); err != nil { t.Fatalf("unexpected error: %v", err) } + if pm.Count() != 0 { t.Errorf("expected 0, got %d", pm.Count()) } +} + +func TestPluginManifestLoadNonexistentDir(t *testing.T) { + pm := NewPluginManager() + if err := pm.LoadFromDir("/nonexistent/path"); err == nil { t.Error("expected error") } +} + +func TestPluginExampleWordCount(t *testing.T) { + dir := t.TempDir() + testFile := filepath.Join(dir, "sample.go") + content := "package main\n\nfunc main() {\n\tprintln(\"hello world\")\n}\n" + os.WriteFile(testFile, []byte(content), 0o644) + + RegisterBuiltin("wordcount", func(config map[string]string) TUIPlugin { + return &mockPlugin{ + name: "wordcount", + renderOut: "Words: 5 · Lines: 4 · Chars: 50", + hints: []HintPair{{Key: "ctrl+r", Label: "refresh"}}, + sidebar: SidebarItem{Icon: "W", Label: "Word Count", Shortcut: "w"}, + } + }) + factory := getBuiltinFactory("wordcount") + if factory == nil { t.Fatal("wordcount not registered") } + plugin := factory(map[string]string{"file": testFile}) + if plugin == nil { t.Fatal("expected non-nil plugin") } + if plugin.Name() != "wordcount" { t.Errorf("expected wordcount, got %s", plugin.Name()) } + styles := NewStyles(Themes[0]) + rendered := plugin.Render(styles, 80, 10) + if !strings.Contains(rendered, "Words:") { t.Errorf("expected Words:, got %q", rendered) } + hints := plugin.Keybindings() + if len(hints) == 0 { t.Error("expected keybindings") } + item := plugin.SidebarItem() + if item.Label != "Word Count" { t.Errorf("expected Word Count, got %q", item.Label) } +} + +func TestPluginConcurrent(t *testing.T) { + pm := NewPluginManager() + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + pm.Register(&mockPlugin{name: "c-" + itoa(n)}) + _ = pm.All() + _, _ = pm.Get("c-" + itoa(n)) + }(i) + } + for i := 0; i < 50; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + pm.Unregister("c-" + itoa(n)) + _ = pm.All() + }(i) + } + wg.Wait() +} + +func TestAccessibilityDefaults(t *testing.T) { + a := NewAccessibilityMode() + if a.HighContrast() { t.Error("default HC should be false") } + if a.ScreenReader() { t.Error("default SR should be false") } + if a.KeyboardOnlyMode() { t.Error("default KO should be false") } + if a.LargeText() { t.Error("default LT should be false") } + if a.ReducedMotion() { t.Error("default RM should be false") } +} + +func TestAccessibilityHighContrast(t *testing.T) { + a := NewAccessibilityMode() + a.SetHighContrast(true) + if !a.HighContrast() { t.Error("HC should be true") } + a.SetHighContrast(false) + if a.HighContrast() { t.Error("HC should be false") } + theme := HighContrastTheme() + if theme.Name != "HighContrast" { t.Errorf("expected HighContrast, got %q", theme.Name) } + if theme.Background != "#000000" { t.Errorf("expected black, got %q", theme.Background) } + if theme.Text != "#FFFFFF" { t.Errorf("expected white, got %q", theme.Text) } + if theme.Accent != "#FFFF00" { t.Errorf("expected yellow, got %q", theme.Accent) } + if theme.Error != "#FF0000" { t.Errorf("expected red, got %q", theme.Error) } + if theme.Success != "#00FF00" { t.Errorf("expected green, got %q", theme.Success) } +} + +func TestAccessibilityScreenReader(t *testing.T) { + a := NewAccessibilityMode() + a.SetScreenReader(true) + if !a.ScreenReader() { t.Error("SR should be true") } + desc := a.Describe(ViewChat) + if !strings.Contains(desc, "Chat view") { t.Errorf("expected Chat view, got %q", desc) } + if !strings.Contains(desc, "Ctrl+S") { t.Errorf("expected Ctrl+S, got %q", desc) } + descTools := a.Describe(ViewTools) + if !strings.Contains(descTools, "Tools view") { t.Errorf("expected Tools view, got %q", descTools) } +} + +func TestAccessibilityDescribeMessage(t *testing.T) { + a := NewAccessibilityMode() + a.SetScreenReader(true) + msg := ChatMessage{Kind: chatUser, Text: "Fix the bug in main.go"} + desc := a.DescribeMessage(msg) + if !strings.Contains(desc, "User message") { t.Errorf("expected User message, got %q", desc) } + if !strings.Contains(desc, "Fix the bug") { t.Errorf("expected text, got %q", desc) } + toolMsg := ChatMessage{Kind: chatTool, Tool: "sin_edit", Result: true} + td := a.DescribeMessage(toolMsg) + if !strings.Contains(td, "Tool call") { t.Errorf("expected Tool call, got %q", td) } + if !strings.Contains(td, "sin_edit") { t.Errorf("expected sin_edit, got %q", td) } + if !strings.Contains(td, "success") { t.Errorf("expected success, got %q", td) } +} + +func TestAccessibilityKeyboardOnly(t *testing.T) { + a := NewAccessibilityMode() + a.SetKeyboardOnlyMode(true) + if !a.KeyboardOnlyMode() { t.Error("KO should be true") } + if a.ShouldShowMouseCursor() { t.Error("should not show mouse cursor") } + a.SetKeyboardOnlyMode(false) + if !a.ShouldShowMouseCursor() { t.Error("should show mouse cursor") } + shortcuts := a.AllKeyboardShortcuts() + if len(shortcuts) == 0 { t.Error("expected shortcuts") } + sv := a.AllKeyboardShortcutsForView(ViewChat) + if len(sv) <= len(shortcuts) { t.Error("expected more shortcuts for view") } +} + +func TestAccessibilityLargeText(t *testing.T) { + a := NewAccessibilityMode() + a.SetLargeText(true) + if !a.LargeText() { t.Error("LT should be true") } + if !a.BoldAll() { t.Error("BoldAll should be true") } + if a.ExtraPadding() != 2 { t.Errorf("expected 2, got %d", a.ExtraPadding()) } + a.SetLargeText(false) + if a.BoldAll() { t.Error("BoldAll should be false") } + if a.ExtraPadding() != 0 { t.Errorf("expected 0, got %d", a.ExtraPadding()) } +} + +func TestAccessibilityReducedMotion(t *testing.T) { + a := NewAccessibilityMode() + a.SetReducedMotion(true) + if !a.ReducedMotion() { t.Error("RM should be true") } + if a.ShouldAnimate() { t.Error("should not animate") } + text := a.SpinnerText(5 * time.Second) + if !strings.Contains(text, "thinking") { t.Errorf("expected thinking, got %q", text) } + if !strings.Contains(text, "5s") { t.Errorf("expected 5s, got %q", text) } + a.SetReducedMotion(false) + if !a.ShouldAnimate() { t.Error("should animate") } + if a.SpinnerText(time.Second) != "" { t.Error("expected empty spinner text") } +} + +func TestAccessibilityStatusText(t *testing.T) { + a := NewAccessibilityMode() + cases := []struct{ s, w string }{{"running", "[RUNNING] "}, {"passed", "[PASSED] "}, {"failed", "[FAILED] "}, {"idle", "[IDLE] "}, {"pending", "[PENDING] "}, {"custom", "[CUSTOM] "}} + for _, c := range cases { + if got := a.StatusText(c.s); got != c.w { t.Errorf("StatusText(%q)=%q, want %q", c.s, got, c.w) } + } +} + +func TestAccessibilityApplyToConfig(t *testing.T) { + a := NewAccessibilityMode() + a.ApplyToConfig(map[string]bool{"high_contrast": true, "screen_reader": true, "reduced_motion": true, "large_text": true}) + if !a.HighContrast() { t.Error("HC should be true") } + if !a.ScreenReader() { t.Error("SR should be true") } + if !a.ReducedMotion() { t.Error("RM should be true") } + if !a.LargeText() { t.Error("LT should be true") } +} + +func TestAccessibilityApplyTheme(t *testing.T) { + a := NewAccessibilityMode() + base := Themes[0] + if a.ApplyTheme(base).Name != base.Name { t.Errorf("expected base theme") } + a.SetHighContrast(true) + if a.ApplyTheme(base).Name != "HighContrast" { t.Errorf("expected HighContrast") } +} + +func TestAccessibilityApplyToStyles(t *testing.T) { + a := NewAccessibilityMode() + base := NewStyles(Themes[0]) + if a.ApplyToStyles(base).Theme.Name != base.Theme.Name { t.Errorf("expected base theme") } + a.SetHighContrast(true) + if a.ApplyToStyles(base).Theme.Name != "HighContrast" { t.Errorf("expected HighContrast") } +} + +func TestAccessibilityConcurrent(t *testing.T) { + a := NewAccessibilityMode() + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 100; i++ { + a.SetHighContrast(i%2 == 0) + _ = a.HighContrast() + _ = a.ShouldAnimate() + _ = a.Describe(ViewChat) + } + }() + for i := 0; i < 100; i++ { + a.SetScreenReader(i%2 == 0) + _ = a.ScreenReader() + _ = a.BoldAll() + _ = a.DescribeMessage(ChatMessage{Kind: chatUser, Text: "test"}) + } + <-done +} diff --git a/cmd/sin-code/tui/plugins/wordcount_plugin.go b/cmd/sin-code/tui/plugins/wordcount_plugin.go new file mode 100644 index 00000000..e0487e56 --- /dev/null +++ b/cmd/sin-code/tui/plugins/wordcount_plugin.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT +package plugins + +import ( + "fmt" + "os" + "strings" + "sync" + "unicode" + + tea "charm.land/bubbletea/v2" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/tui" +) + +type WordCountPlugin struct { + mu sync.RWMutex + filePath string + words int + lines int + chars int + config map[string]string +} + +func NewWordCountPlugin(config map[string]string) *WordCountPlugin { + p := &WordCountPlugin{config: config} + if path, ok := config["file"]; ok { + p.filePath = path + p.refresh() + } + return p +} + +func (w *WordCountPlugin) Name() string { return "wordcount" } + +func (w *WordCountPlugin) Render(styles tui.Styles, width, height int) string { + w.mu.RLock() + defer w.mu.RUnlock() + if width < 20 { + width = 20 + } + label := "Word Count" + if w.filePath != "" { + label = "Word Count: " + shortenPath(w.filePath, 20) + } + hdr := styles.ContentHdr.Render(label) + body := styles.Content.Render(fmt.Sprintf("Words: %d · Lines: %d · Chars: %d", w.words, w.lines, w.chars)) + return hdr + "\n" + body +} + +func (w *WordCountPlugin) Update(msg tea.Msg) (handled bool) { + switch msg.(type) { + case tui.ToolRunMsg: + w.mu.Lock() + w.refresh() + w.mu.Unlock() + return true + case tea.KeyMsg: + return true + } + return false +} + +func (w *WordCountPlugin) Keybindings() []tui.HintPair { + return []tui.HintPair{{Key: "ctrl+r", Label: "refresh"}} +} + +func (w *WordCountPlugin) SidebarItem() tui.SidebarItem { + return tui.SidebarItem{View: -1, Icon: "W", Label: "Word Count", Shortcut: "w"} +} + +func (w *WordCountPlugin) SetFile(path string) { + w.mu.Lock() + defer w.mu.Unlock() + w.filePath = path + w.refresh() +} + +func (w *WordCountPlugin) Stats() (words, lines, chars int) { + w.mu.RLock() + defer w.mu.RUnlock() + return w.words, w.lines, w.chars +} + +func (w *WordCountPlugin) refresh() { + if w.filePath == "" { + w.words, w.lines, w.chars = 0, 0, 0 + return + } + data, err := os.ReadFile(w.filePath) + if err != nil { + w.words, w.lines, w.chars = 0, 0, 0 + return + } + text := string(data) + w.chars = len(data) + w.lines = strings.Count(text, "\n") + 1 + if len(strings.TrimSpace(text)) == 0 { + w.words = 0 + } else { + w.words = countWords(text) + } +} + +func countWords(s string) int { + count := 0 + inWord := false + for _, r := range s { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' { + if !inWord { + count++ + inWord = true + } + } else { + inWord = false + } + } + return count +} + +func shortenPath(p string, maxLen int) string { + if len(p) <= maxLen { + return p + } + parts := strings.Split(p, "/") + if len(parts) <= 2 { + return p + } + return "…/" + parts[len(parts)-1] +} + +func init() { + tui.RegisterBuiltin("wordcount", func(config map[string]string) tui.TUIPlugin { + return NewWordCountPlugin(config) + }) +} diff --git a/cmd/sin-code/tui/render_cache.go b/cmd/sin-code/tui/render_cache.go new file mode 100644 index 00000000..5d9e1fd2 --- /dev/null +++ b/cmd/sin-code/tui/render_cache.go @@ -0,0 +1,52 @@ +package tui + +import ( + "container/list" + "crypto/sha256" + "fmt" + "strings" + "sync" +) + +type cacheEntry struct { key string; value string } + +type RenderCache struct { + mu sync.RWMutex; maxEntries int; entries *list.List; index map[string]*list.Element + hits int; misses int; evictions int +} + +func NewRenderCache(maxEntries int) *RenderCache { + if maxEntries < 0 { maxEntries = 0 } + return &RenderCache{maxEntries: maxEntries, entries: list.New(), index: make(map[string]*list.Element)} +} + +func (c *RenderCache) Get(key string) (string, bool) { + c.mu.Lock(); defer c.mu.Unlock() + if el, ok := c.index[key]; ok { c.entries.MoveToFront(el); c.hits++; return el.Value.(*cacheEntry).value, true } + c.misses++; return "", false +} + +func (c *RenderCache) Set(key, value string) { + c.mu.Lock(); defer c.mu.Unlock() + if c.maxEntries == 0 { return } + if el, ok := c.index[key]; ok { el.Value.(*cacheEntry).value = value; c.entries.MoveToFront(el); return } + entry := &cacheEntry{key: key, value: value} + el := c.entries.PushFront(entry); c.index[key] = el + if c.entries.Len() > c.maxEntries { oldest := c.entries.Back(); if oldest != nil { c.entries.Remove(oldest); delete(c.index, oldest.Value.(*cacheEntry).key); c.evictions++ } } +} + +func (c *RenderCache) Invalidate(msgIdx int) { + c.mu.Lock(); defer c.mu.Unlock() + prefix := fmt.Sprintf("msg-%d:", msgIdx) + var toRemove []*list.Element + for el := c.entries.Front(); el != nil; el = el.Next() { if strings.HasPrefix(el.Value.(*cacheEntry).key, prefix) { toRemove = append(toRemove, el) } } + for _, el := range toRemove { c.entries.Remove(el); delete(c.index, el.Value.(*cacheEntry).key) } +} + +func (c *RenderCache) Clear() { c.mu.Lock(); defer c.mu.Unlock(); c.entries.Init(); c.index = make(map[string]*list.Element) } +func (c *RenderCache) Stats() (hits, misses, evictions int) { c.mu.RLock(); defer c.mu.RUnlock(); return c.hits, c.misses, c.evictions } + +func renderCacheKey(msgIdx int, content string, width int, themeName string) string { + h := sha256.Sum256([]byte(fmt.Sprintf("%d|%s|%d|%s", msgIdx, content, width, themeName))) + return fmt.Sprintf("msg-%d:%x", msgIdx, h[:8]) +} diff --git a/cmd/sin-code/tui/resize_test.go b/cmd/sin-code/tui/resize_test.go new file mode 100644 index 00000000..a0c19982 --- /dev/null +++ b/cmd/sin-code/tui/resize_test.go @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: MIT +package tui + +import ( + "sync" + "testing" + + tea "charm.land/bubbletea/v2" +) + +func TestResizeEnlarge(t *testing.T) { + m := NewModel() + m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + if m.Width != 80 || m.Height != 24 { + t.Fatalf("expected 80x24, got %dx%d", m.Width, m.Height) + } + + m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + if m.Width != 120 || m.Height != 40 { + t.Errorf("expected 120x40, got %dx%d", m.Width, m.Height) + } + v := m.View() + if v.Content == "" { + t.Error("View should produce content at 120x40") + } +} + +func TestResizeShrink(t *testing.T) { + m := NewModel() + m.ChatHistory = benchChatHistory(10) + m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + + m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + if m.Width != 80 || m.Height != 24 { + t.Errorf("expected 80x24, got %dx%d", m.Width, m.Height) + } + v := m.View() + if v.Content == "" { + t.Error("View should produce content at 80x24") + } +} + +func TestResizeVerySmall(t *testing.T) { + m := NewModel() + m.Update(tea.WindowSizeMsg{Width: 20, Height: 5}) + + if m.Width != 20 || m.Height != 5 { + t.Errorf("expected 20x5, got %dx%d", m.Width, m.Height) + } + v := m.View() + if v.Content == "" { + t.Error("View should still produce content at 20x5") + } +} + +func TestResizeVeryLarge(t *testing.T) { + m := NewModel() + m.Update(tea.WindowSizeMsg{Width: 400, Height: 100}) + + if m.Width != 400 || m.Height != 100 { + t.Errorf("expected 400x100, got %dx%d", m.Width, m.Height) + } + v := m.View() + if v.Content == "" { + t.Error("View should produce content at 400x100") + } + lines := 0 + for _, c := range v.Content { + if c == '\n' { + lines++ + } + } + if lines > 100 { + t.Errorf("content should not exceed 100 lines, got %d", lines) + } +} + +func TestResizeRapidNoRace(t *testing.T) { + m := NewModel() + m.ChatHistory = benchChatHistory(20) + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 10; i++ { + w := 80 + i*20 + h := 24 + i*4 + m.Update(tea.WindowSizeMsg{Width: w, Height: h}) + _ = m.View() + } + }() + wg.Wait() +} + +func TestResizeTmuxSplit(t *testing.T) { + m := NewModel() + m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + + m.Update(tea.WindowSizeMsg{Width: 60, Height: 40}) + if m.Width != 60 { + t.Errorf("expected width 60, got %d", m.Width) + } + v := m.View() + if v.Content == "" { + t.Error("View should produce content after tmux split") + } +} + +func TestResizeZeroHeight(t *testing.T) { + m := NewModel() + m.Update(tea.WindowSizeMsg{Width: 80, Height: 0}) + v := m.View() + if v.Content == "" { + t.Error("View should produce content even with 0 height") + } +} + +func TestResizeZeroWidth(t *testing.T) { + m := NewModel() + m.Update(tea.WindowSizeMsg{Width: 0, Height: 24}) + v := m.View() + if v.Content == "" { + t.Error("View should produce content even with 0 width") + } +} + +func TestPadContentEdgeDimensions(t *testing.T) { + result := padContent("hello", 1, 1) + if result == "" { + t.Error("padContent should not return empty for 1x1") + } + + result = padContent("hello\nworld", 0, 0) + if result != "" { + t.Error("padContent with 0x0 should return empty") + } + + result = padContent("", 5, 3) + if result == "" { + t.Error("padContent with empty string should still pad") + } +} + +func TestPadRightEdgeDimensions(t *testing.T) { + result := padRight("hi", 0) + if result != "hi" { + t.Errorf("padRight with width 0 should return original, got %q", result) + } + + result = padRight("hello", 3) + if result != "hello" { + t.Errorf("padRight with width < len should return original, got %q", result) + } + + result = padRight("", 5) + if len(result) != 5 { + t.Errorf("padRight empty with width 5 should be 5 chars, got %d", len(result)) + } +} + +func TestSplitLinesEdgeDimensions(t *testing.T) { + result := splitLines("a\nb\nc", 5, 2) + if result == "" { + t.Error("splitLines should not return empty") + } + + result = splitLines("a\nb\nc", 5, 0) + if result != "" { + t.Error("splitLines with height 0 should return empty") + } + + result = splitLines("", 5, 3) + if result == "" { + t.Error("splitLines with empty string should still produce padded lines") + } +} + +func TestComposeLayoutOneByOne(t *testing.T) { + m := NewModel() + sidebar := NewSidebar() + footer := NewFooter(1) + tabs := NewTabs() + tabs.Width = 1 + + result := ComposeLayout(tabs, sidebar, ViewChat, "test", "", footer, m.Styles, 1, 1) + if result == "" { + t.Error("ComposeLayout should produce output for 1x1") + } +} diff --git a/cmd/sin-code/tui/theme_custom.go b/cmd/sin-code/tui/theme_custom.go new file mode 100644 index 00000000..41459c25 --- /dev/null +++ b/cmd/sin-code/tui/theme_custom.go @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +package tui + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type CustomTheme struct { + Name string `json:"name"` + Accent string `json:"accent"` + AccentDim string `json:"accent_dim"` + Text string `json:"text"` + TextDim string `json:"text_dim"` + Background string `json:"background"` + Border string `json:"border"` + Success string `json:"success"` + Warn string `json:"warn"` + Error string `json:"error"` +} + +func CustomThemePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config", "sin-code", "theme.json"), nil +} + +func LoadCustomTheme(path string) (*Theme, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read theme file: %w", err) + } + var ct CustomTheme + if err := json.Unmarshal(data, &ct); err != nil { + return nil, fmt.Errorf("parse theme JSON: %w", err) + } + theme := ct.ToTheme() + if err := theme.Validate(); err != nil { + return nil, fmt.Errorf("invalid theme: %w", err) + } + return &theme, nil +} + +func SaveCustomTheme(theme Theme, path string) error { + ct := CustomTheme{ + Name: theme.Name, + Accent: theme.Accent, + AccentDim: theme.AccentDim, + Text: theme.Text, + TextDim: theme.TextDim, + Background: theme.Background, + Border: theme.Border, + Success: theme.Success, + Warn: theme.Warn, + Error: theme.Error, + } + data, err := json.MarshalIndent(ct, "", " ") + if err != nil { + return fmt.Errorf("marshal theme: %w", err) + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create theme dir: %w", err) + } + return os.WriteFile(path, data, 0o644) +} + +func (ct CustomTheme) ToTheme() Theme { + return Theme{ + Name: ct.Name, + Accent: ct.Accent, + AccentDim: ct.AccentDim, + Text: ct.Text, + TextDim: ct.TextDim, + Background: ct.Background, + Border: ct.Border, + Success: ct.Success, + Warn: ct.Warn, + Error: ct.Error, + } +} + +func (t *Theme) Validate() error { + if t.Name == "" { + return fmt.Errorf("theme name is required") + } + colorFields := []struct { + name string + value string + }{ + {"accent", t.Accent}, + {"accent_dim", t.AccentDim}, + {"text", t.Text}, + {"text_dim", t.TextDim}, + {"background", t.Background}, + {"border", t.Border}, + {"success", t.Success}, + {"warn", t.Warn}, + {"error", t.Error}, + } + for _, f := range colorFields { + if f.value == "" { + return fmt.Errorf("color field %s is empty", f.name) + } + if !isValidHexColor(f.value) { + return fmt.Errorf("color field %s has invalid hex color: %s", f.name, f.value) + } + } + return nil +} + +func isValidHexColor(s string) bool { + if !strings.HasPrefix(s, "#") { + return false + } + hex := s[1:] + if len(hex) != 3 && len(hex) != 6 && len(hex) != 8 { + return false + } + for _, c := range hex { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} + +func (m *Model) LoadCustomThemeFromPath(path string) error { + theme, err := LoadCustomTheme(path) + if err != nil { + return err + } + m.Styles = NewStyles(*theme) + m.AppendHistory(m.ViewKind.String(), "theme-custom", theme.Name, true) + return nil +} + +func (m *Model) ExportThemeToPath(path string) error { + theme := Themes[m.ThemeIdx] + if err := SaveCustomTheme(theme, path); err != nil { + return err + } + m.AppendHistory(m.ViewKind.String(), "theme-export", path, true) + return nil +} diff --git a/cmd/sin-code/tui/update.go b/cmd/sin-code/tui/update.go index 4dc025de..3dc1b8d7 100644 --- a/cmd/sin-code/tui/update.go +++ b/cmd/sin-code/tui/update.go @@ -431,6 +431,8 @@ func isGlobalHotkey(msg tea.KeyMsg) bool { return true case key == "esc", key == "q": return true + case key == "?": + return true case key == "f2": return true case key >= "0" && key <= "9": @@ -464,6 +466,15 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.Mode == ModeModelSelector { return m.handleModelSelectorKey(msg) } + if m.Mode == ModeModelSwitcher { + return m.handleModelSwitcherKey(msg) + } + if m.Mode == ModeHelpOverlay { + return m.handleHelpOverlayKey(msg) + } + if m.Mode == ModeFilePicker { + return m.handleFilePickerKey(msg) + } if m.Mode == ModePermissionDialog { k := msg.String() if k == "a" || k == "A" { @@ -588,12 +599,21 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, keySplitPane): m.ToggleSplitPane() return m, nil + case msg.String() == "ctrl+o": + m.OpenFilePicker() + return m, nil case key.Matches(msg, keyClosePreview) && m.FilePreview != "": m.ClearFilePreview() return m, nil case key.Matches(msg, keymap.SessionSwitch): m.OpenSessionSwitcher() return m, nil + case key.Matches(msg, keymap.Help): + if m.HelpOverlay != nil { + m.HelpOverlay.Open() + m.Mode = ModeHelpOverlay + } + return m, nil case key.Matches(msg, keymap.ModelSelect): m.OpenModelSelector() return m, nil @@ -996,6 +1016,21 @@ func (m *Model) View() tea.View { popup := RenderModelSelector(m.ModelSelector, m.Styles, m.Width, m.Height) layout = lipgloss.Place(m.Width, m.Height, lipgloss.Center, lipgloss.Center, popup) } + if m.Mode == ModeModelSwitcher && m.ModelSwitcher != nil { + popup := m.ModelSwitcher.Render(m.Styles, m.Width, m.Height) + if popup != "" { + layout = lipgloss.Place(m.Width, m.Height, lipgloss.Center, lipgloss.Center, popup) + } + } + if m.Mode == ModeHelpOverlay && m.HelpOverlay != nil { + layout = m.HelpOverlay.Render(m.Styles, m.Width, m.Height) + } + if m.Mode == ModeFilePicker && m.FilePicker != nil { + popup := m.FilePicker.Render(m.Styles, m.Width, m.Height) + if popup != "" { + layout = lipgloss.Place(m.Width, m.Height, lipgloss.Center, lipgloss.Center, popup) + } + } if m.Mode == ModePermissionDialog && m.Footer.PermissionPopup != nil { req := PermissionRequest{ Tool: m.PermissionDialog.ToolName, @@ -1302,7 +1337,7 @@ func copyToClipboard(text string) { _ = cmd.Run() } -func (m *Model) handleMouseAction(action MouseAction) tea.Cmd { +func (m *Model) handleMouseAction(action MouseResolution) tea.Cmd { switch action.Kind { case "click": return m.handleMouseClick(action) @@ -1314,7 +1349,7 @@ func (m *Model) handleMouseAction(action MouseAction) tea.Cmd { return nil } -func (m *Model) handleMouseClick(action MouseAction) tea.Cmd { +func (m *Model) handleMouseClick(action MouseResolution) tea.Cmd { switch action.Target { case "sidebar": return m.handleSidebarClick(action) @@ -1333,7 +1368,7 @@ func (m *Model) handleMouseClick(action MouseAction) tea.Cmd { return nil } -func (m *Model) handleSidebarClick(action MouseAction) tea.Cmd { +func (m *Model) handleSidebarClick(action MouseResolution) tea.Cmd { const tabBarHeight = 3 const sidebarHeaderHeight = 2 // header + separator @@ -1364,7 +1399,7 @@ func (m *Model) handleSidebarClick(action MouseAction) tea.Cmd { return nil } -func (m *Model) handleTabsClick(action MouseAction) tea.Cmd { +func (m *Model) handleTabsClick(action MouseResolution) tea.Cmd { const tabStartX = 12 // "⚡ sin-code" header + space const tabWidth = 15 if action.X < tabStartX { @@ -1377,14 +1412,14 @@ func (m *Model) handleTabsClick(action MouseAction) tea.Cmd { return nil } -func (m *Model) handleMouseScrollUp(action MouseAction) tea.Cmd { +func (m *Model) handleMouseScrollUp(action MouseResolution) tea.Cmd { if m.ViewKind == ViewChat { m.ChatViewport.ScrollUp(3) } return nil } -func (m *Model) handleMouseScrollDown(action MouseAction) tea.Cmd { +func (m *Model) handleMouseScrollDown(action MouseResolution) tea.Cmd { if m.ViewKind == ViewChat { m.ChatViewport.ScrollDown(3) } diff --git a/docs/release-notes/v3.20.0.md b/docs/release-notes/v3.20.0.md index 4b4426a4..3ee9fca7 100644 --- a/docs/release-notes/v3.20.0.md +++ b/docs/release-notes/v3.20.0.md @@ -274,7 +274,7 @@ Commits in this release: 243 deprecated` markers with `lifecycle:` frontmatter. `scripts/lifecycle_map.yaml` single source of truth. - **Skill distribution to external agents** (issue #169) — `sin-code - skill install --agent ` for 8 agent families. + skill install --agent ` for 11 agent families. - **Shop skill integration** (issue #142) — cj-dropshipping, stripe, tiktok-shop skills registered in `KnownSkills()`. - **`sin-code spec`** (issue #157) — self-authoring spec loop with drift diff --git a/skills/browser-skills/skill-browser-tools/SKILL.md b/skills/browser-skills/skill-browser-tools/SKILL.md index 0ad57474..f3dac98d 100644 --- a/skills/browser-skills/skill-browser-tools/SKILL.md +++ b/skills/browser-skills/skill-browser-tools/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: author: SIN-Code version: 3.20.0 +required_tools: + - sin_scout + - sin_execute lifecycle: external --- diff --git a/skills/code-skills/skill-code-codocs/SKILL.md b/skills/code-skills/skill-code-codocs/SKILL.md index aeb1721a..673408a6 100644 --- a/skills/code-skills/skill-code-codocs/SKILL.md +++ b/skills/code-skills/skill-code-codocs/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: author: SIN-Code version: 3.20.0 +required_tools: + - sin_edit + - sin_read lifecycle: external --- diff --git a/skills/code-skills/skill-code-create/SKILL.md b/skills/code-skills/skill-code-create/SKILL.md index 944318c6..ef75725f 100644 --- a/skills/code-skills/skill-code-create/SKILL.md +++ b/skills/code-skills/skill-code-create/SKILL.md @@ -74,6 +74,22 @@ ADD CONTEXT/FRAMEWORKS/TASKS/TEMPLATES → VALIDATE → UPDATE REGISTRY & DOCS - Must be valid kebab-case: `^[a-z0-9]+(-[a-z0-9]+)*$`. - Skills ported from external repos must include `lifecycle: external` and `sources:` in `SKILL.md` metadata. +### `required_tools` Frontmatter Field + +- **What:** An optional YAML frontmatter field that binds a skill to specific SIN tools. Example: + ```yaml + required_tools: [sin_edit, sin_test, sin_quality_gate] + ``` + or as a YAML block list: + ```yaml + required_tools: + - sin_edit + - sin_test + ``` +- **When to use:** When the skill has a deterministic tool dependency — i.e., the skill's workflow cannot be completed without calling a specific SIN tool. Omit the field if the skill has no hard tool requirement. +- **How it works:** Parsed by `cmd/sin-code/internal/skillmgr/required_tools.go` and merged into `CoverageRequiredTools` on the agent loop. Enforced at runtime by the `ToolCoverageEnforcer` (issue #248): if the model completes a run without invoking every listed tool, the loop rejects the completion and re-injects the violation as open criteria. +- **Validation:** `scripts/validate_skill.py --strict` validates that `required_tools` (if present) is a YAML list of known SIN tool names. + ## External Skills - External skills live in `~/.config/opencode/skills/` or are registered as MCP servers. diff --git a/skills/code-skills/skill-code-create/frameworks/standards.md b/skills/code-skills/skill-code-create/frameworks/standards.md index f2db0ed3..6061e90d 100644 --- a/skills/code-skills/skill-code-create/frameworks/standards.md +++ b/skills/code-skills/skill-code-create/frameworks/standards.md @@ -11,17 +11,19 @@ Docs: ../SKILL.md ## Skill Standard -- `SKILL.md` with YAML frontmatter (`name`, `description`, `license`, `compatibility`, `metadata`). +- `SKILL.md` with YAML frontmatter (`name`, `description`, `license`, `compatibility`, `metadata`, `required_tools` (optional, YAML list of SIN tool names)). - Required directories: `context/`, `frameworks/`, `tasks/`, `templates/`. - Recommended directories: `scripts/`, `tests/`, `lib/`. - `compatibility` must be a YAML list. - `metadata` should include `author`, `version`, and for external skills `lifecycle: external` plus `sources:`. +- `required_tools` (optional): a YAML list of SIN tool names (e.g. `[sin_edit, sin_test]`). Parsed by `skillmgr/required_tools.go` and enforced by `ToolCoverageEnforcer`. ## Bundled Skill Layout ``` skills/-skills/skill--/ -├── SKILL.md +├── SKILL.md # frontmatter: name, description, license, compatibility, +│ # metadata, required_tools (optional) ├── LICENSE ├── context/ │ └── triggers.md @@ -41,6 +43,7 @@ skills/-skills/skill--/ - No copyrighted material without license. - Keep templates actionable. - External/port skills must include `lifecycle: external` and `sources:` in `SKILL.md` metadata. +- If `required_tools` is present, it must be a YAML list of valid SIN tool names. - Bundled skill changes require updating `README.md`, `AGENTS.md`, `CHANGELOG.md`, and `ECOSYSTEM.md` in the same PR. ## Quality Gates diff --git a/skills/code-skills/skill-code-create/tasks/workflow.md b/skills/code-skills/skill-code-create/tasks/workflow.md index caad5abd..f39f4a4e 100644 --- a/skills/code-skills/skill-code-create/tasks/workflow.md +++ b/skills/code-skills/skill-code-create/tasks/workflow.md @@ -17,6 +17,9 @@ Docs: ../SKILL.md - [ ] Task 2: Write `SKILL.md`. - Acceptance: Frontmatter + overview + when to use + core process + naming rules + verification. - Verify: Validator parses frontmatter. +- [ ] Task 2b: If the skill has deterministic tool dependencies, add `required_tools:` YAML list to frontmatter. + - Acceptance: `required_tools` is a YAML list of valid SIN tool names (or omitted if not applicable). + - Verify: `python3 scripts/validate_skill.py --strict` passes with the field present. - [ ] Task 3: Fill `context/triggers.md`. - Acceptance: Triggers, boundaries, required input, tone documented. - Verify: File not empty. diff --git a/skills/code-skills/skill-code-create/templates/output.md b/skills/code-skills/skill-code-create/templates/output.md index 6e1bd269..44c6afcf 100644 --- a/skills/code-skills/skill-code-create/templates/output.md +++ b/skills/code-skills/skill-code-create/templates/output.md @@ -23,6 +23,7 @@ Docs: ../SKILL.md ## Naming - Bundled name: `skill-{category}-{descriptive-name}` - Frontmatter `name:` matches directory name. +- `required_tools` (if present): YAML list of SIN tools. ## Validation - `python3 scripts/validate_skill.py --all-bundled --strict`: pass diff --git a/skills/code-skills/skill-code-create/templates/prompt.md b/skills/code-skills/skill-code-create/templates/prompt.md index f83502e6..f8e4993c 100644 --- a/skills/code-skills/skill-code-create/templates/prompt.md +++ b/skills/code-skills/skill-code-create/templates/prompt.md @@ -16,6 +16,7 @@ Constraints: - Directory must be `skills/{category}-skills/{name}/`. - Write YAML frontmatter with `name`, `description`, `license`, `compatibility`, `metadata`. - For external/port skills, include `lifecycle: external` and `sources:` in metadata. +- If the skill has deterministic tool dependencies, add `required_tools:` as a YAML list (e.g. `[sin_edit, sin_test]`). - Include required directories: `context/`, `frameworks/`, `tasks/`, `templates/`. - Add a `LICENSE` file. - Validate with `python3 scripts/validate_skill.py --all-bundled --strict`. diff --git a/skills/code-skills/skill-code-docs/SKILL.md b/skills/code-skills/skill-code-docs/SKILL.md index 2f474689..252b21b4 100644 --- a/skills/code-skills/skill-code-docs/SKILL.md +++ b/skills/code-skills/skill-code-docs/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: author: SIN-Code version: 3.20.0 +required_tools: + - sin_write + - sin_read lifecycle: external --- diff --git a/skills/code-skills/skill-code-graph/SKILL.md b/skills/code-skills/skill-code-graph/SKILL.md index 08a9c8a2..fec75a20 100644 --- a/skills/code-skills/skill-code-graph/SKILL.md +++ b/skills/code-skills/skill-code-graph/SKILL.md @@ -8,8 +8,10 @@ compatibility: metadata: author: SIN-Rotator version: 2.0.0 - lifecycle: external sources: "OpenSIN-Code/Infra-SIN-OpenCode-Stack/skills/skill-code-graph" +required_tools: + - sin_image_graph +lifecycle: external --- # skill-code-graph — SOTA Chart Generation diff --git a/skills/code-skills/skill-code-mcp-builder/SKILL.md b/skills/code-skills/skill-code-mcp-builder/SKILL.md index b686437b..6254544c 100644 --- a/skills/code-skills/skill-code-mcp-builder/SKILL.md +++ b/skills/code-skills/skill-code-mcp-builder/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: author: SIN-Code version: 3.20.0 +required_tools: + - sin_write + - sin_edit lifecycle: external --- diff --git a/skills/design-skills/skill-design-frontend/SKILL.md b/skills/design-skills/skill-design-frontend/SKILL.md index 8926196e..5c952a16 100644 --- a/skills/design-skills/skill-design-frontend/SKILL.md +++ b/skills/design-skills/skill-design-frontend/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: author: SIN-Code version: 3.20.0 +required_tools: + - sin_image_graph + - sin_write lifecycle: external --- diff --git a/skills/design-skills/skill-design-image/SKILL.md b/skills/design-skills/skill-design-image/SKILL.md index fb75a305..fd656ded 100644 --- a/skills/design-skills/skill-design-image/SKILL.md +++ b/skills/design-skills/skill-design-image/SKILL.md @@ -10,6 +10,8 @@ compatibility: metadata: author: SIN-Code version: 3.20.0 +required_tools: + - sin_image_graph lifecycle: native --- diff --git a/skills/ecosystem-skills/skill-ecosystem-context/SKILL.md b/skills/ecosystem-skills/skill-ecosystem-context/SKILL.md index 765eb855..c1385937 100644 --- a/skills/ecosystem-skills/skill-ecosystem-context/SKILL.md +++ b/skills/ecosystem-skills/skill-ecosystem-context/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: author: SIN-Code version: 3.20.0 +required_tools: + - sin_memory_search + - sin_sckg lifecycle: external --- diff --git a/skills/ecosystem-skills/skill-ecosystem-marketplace/SKILL.md b/skills/ecosystem-skills/skill-ecosystem-marketplace/SKILL.md index 6ba370f8..044a013b 100644 --- a/skills/ecosystem-skills/skill-ecosystem-marketplace/SKILL.md +++ b/skills/ecosystem-skills/skill-ecosystem-marketplace/SKILL.md @@ -10,6 +10,8 @@ compatibility: metadata: author: SIN-Code version: 3.20.0 +required_tools: + - sin_execute lifecycle: external --- diff --git a/skills/github-skills/skill-github-account/SKILL.md b/skills/github-skills/skill-github-account/SKILL.md index 11ea0091..0d2e6876 100644 --- a/skills/github-skills/skill-github-account/SKILL.md +++ b/skills/github-skills/skill-github-account/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: author: SIN-Code version: 3.20.0 +required_tools: + - sin_execute + - sin_harvest lifecycle: native --- diff --git a/skills/github-skills/skill-github-actions/SKILL.md b/skills/github-skills/skill-github-actions/SKILL.md index f6131d55..445c0c09 100644 --- a/skills/github-skills/skill-github-actions/SKILL.md +++ b/skills/github-skills/skill-github-actions/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: author: SIN-Code version: 3.20.0 +required_tools: + - sin_write + - sin_execute lifecycle: native --- diff --git a/skills/github-skills/skill-github-app/SKILL.md b/skills/github-skills/skill-github-app/SKILL.md index 21e90877..e1c9b618 100644 --- a/skills/github-skills/skill-github-app/SKILL.md +++ b/skills/github-skills/skill-github-app/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: author: SIN-Code version: 3.20.0 +required_tools: + - sin_execute + - sin_harvest lifecycle: native --- diff --git a/skills/github-skills/skill-github-governance/SKILL.md b/skills/github-skills/skill-github-governance/SKILL.md index 78c46fec..c36a32c4 100644 --- a/skills/github-skills/skill-github-governance/SKILL.md +++ b/skills/github-skills/skill-github-governance/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: coupled_with: skill-github-readme sources: "OpenSIN-Code/Infra-SIN-OpenCode-Stack/skills/sovereign-repo-governance" +required_tools: + - sin_execute + - sin_orchestrate lifecycle: external --- diff --git a/skills/github-skills/skill-github-readme/SKILL.md b/skills/github-skills/skill-github-readme/SKILL.md index 467c6317..71f8afe6 100644 --- a/skills/github-skills/skill-github-readme/SKILL.md +++ b/skills/github-skills/skill-github-readme/SKILL.md @@ -16,6 +16,9 @@ metadata: version: "4.0" last_updated: "2026-06-17" sources: "OpenSIN-Code/Infra-SIN-OpenCode-Stack/skills/visual-repo" +required_tools: + - sin_write + - sin_image_graph lifecycle: external --- diff --git a/skills/infrastructure-skills/skill-infrastructure-cloudflare/SKILL.md b/skills/infrastructure-skills/skill-infrastructure-cloudflare/SKILL.md index e1253ca0..cef8e6eb 100644 --- a/skills/infrastructure-skills/skill-infrastructure-cloudflare/SKILL.md +++ b/skills/infrastructure-skills/skill-infrastructure-cloudflare/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: author: SIN-Code version: 1.0.0 +required_tools: + - sin_execute + - sin_harvest lifecycle: external sources: - diff --git a/skills/infrastructure-skills/skill-infrastructure-oci-vm/SKILL.md b/skills/infrastructure-skills/skill-infrastructure-oci-vm/SKILL.md index cbece269..252cff81 100644 --- a/skills/infrastructure-skills/skill-infrastructure-oci-vm/SKILL.md +++ b/skills/infrastructure-skills/skill-infrastructure-oci-vm/SKILL.md @@ -10,6 +10,8 @@ compatibility: metadata: author: SIN-Code version: 1.0.0 +required_tools: + - sin_execute lifecycle: external sources: - diff --git a/skills/infrastructure-skills/skill-infrastructure-supabase/SKILL.md b/skills/infrastructure-skills/skill-infrastructure-supabase/SKILL.md index 57410add..cc06af51 100644 --- a/skills/infrastructure-skills/skill-infrastructure-supabase/SKILL.md +++ b/skills/infrastructure-skills/skill-infrastructure-supabase/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: author: SIN-Code version: 1.0.0 +required_tools: + - sin_execute + - sin_write lifecycle: external sources: - diff --git a/skills/memory-skills/skill-memory-honcho-rollback/SKILL.md b/skills/memory-skills/skill-memory-honcho-rollback/SKILL.md index d17350e0..7e4024dc 100644 --- a/skills/memory-skills/skill-memory-honcho-rollback/SKILL.md +++ b/skills/memory-skills/skill-memory-honcho-rollback/SKILL.md @@ -10,6 +10,8 @@ compatibility: metadata: author: SIN-Code version: 3.20.0 +required_tools: + - sin_memory_search lifecycle: external --- diff --git a/skills/memory-skills/skill-memory-honcho/SKILL.md b/skills/memory-skills/skill-memory-honcho/SKILL.md index 694275d8..d6b9f606 100644 --- a/skills/memory-skills/skill-memory-honcho/SKILL.md +++ b/skills/memory-skills/skill-memory-honcho/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: author: SIN-Code version: 3.20.0 +required_tools: + - sin_memory_add + - sin_memory_search lifecycle: external --- diff --git a/skills/memory-skills/skill-memory-infisical/SKILL.md b/skills/memory-skills/skill-memory-infisical/SKILL.md index 4c0dc051..1bac552c 100644 --- a/skills/memory-skills/skill-memory-infisical/SKILL.md +++ b/skills/memory-skills/skill-memory-infisical/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: author: SIN-Code version: 3.20.0 +required_tools: + - sin_execute + - sin_harvest lifecycle: external --- diff --git a/skills/planning-skills/skill-planning-enterprise/SKILL.md b/skills/planning-skills/skill-planning-enterprise/SKILL.md index a911da78..a2f86c88 100644 --- a/skills/planning-skills/skill-planning-enterprise/SKILL.md +++ b/skills/planning-skills/skill-planning-enterprise/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: author: SIN-Code version: 3.20.0 +required_tools: + - sin_sckg + - sin_orchestrate lifecycle: native sources: --- diff --git a/skills/process-skills/skill-code-lazy/SKILL.md b/skills/process-skills/skill-code-lazy/SKILL.md index 57783161..cf5ba62c 100644 --- a/skills/process-skills/skill-code-lazy/SKILL.md +++ b/skills/process-skills/skill-code-lazy/SKILL.md @@ -23,6 +23,9 @@ metadata: - https://github.com/DietrichGebert/ponytail/blob/main/skills/ponytail/SKILL.md sin-mandate: M3 (verification gate is sacred) activation-keyword: lazy_skill +required_tools: + - sin_poc + - sin_oracle --- # skill-code-lazy (SIN-Code variant of ponytail) diff --git a/skills/process-skills/skill-process-goal/SKILL.md b/skills/process-skills/skill-process-goal/SKILL.md index 6b6b0274..c7455423 100644 --- a/skills/process-skills/skill-process-goal/SKILL.md +++ b/skills/process-skills/skill-process-goal/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: author: SIN-Code version: 3.20.0 +required_tools: + - sin_orchestrate + - sin_sckg lifecycle: native --- diff --git a/skills/process-skills/skill-process-grill/SKILL.md b/skills/process-skills/skill-process-grill/SKILL.md index 16be7ae1..96a5f2d5 100644 --- a/skills/process-skills/skill-process-grill/SKILL.md +++ b/skills/process-skills/skill-process-grill/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: author: SIN-Code version: 3.20.0 +required_tools: + - sin_sckg + - sin_grasp lifecycle: external --- diff --git a/skills/process-skills/skill-process-scheduler/SKILL.md b/skills/process-skills/skill-process-scheduler/SKILL.md index 31242704..3bbec836 100644 --- a/skills/process-skills/skill-process-scheduler/SKILL.md +++ b/skills/process-skills/skill-process-scheduler/SKILL.md @@ -10,6 +10,8 @@ compatibility: metadata: author: SIN-Code version: 3.20.0 +required_tools: + - sin_execute lifecycle: external --- diff --git a/skills/shop-skills/skill-shop-cj-dropshipping/SKILL.md b/skills/shop-skills/skill-shop-cj-dropshipping/SKILL.md index 8f85650a..9c0e3197 100644 --- a/skills/shop-skills/skill-shop-cj-dropshipping/SKILL.md +++ b/skills/shop-skills/skill-shop-cj-dropshipping/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: author: SIN-Code version: 1.0.0 +required_tools: + - sin_execute + - sin_harvest lifecycle: external sources: - https://github.com/OpenSIN-Code/cj-dropshipping-skill diff --git a/skills/shop-skills/skill-shop-stripe/SKILL.md b/skills/shop-skills/skill-shop-stripe/SKILL.md index 640504da..c205c2ee 100644 --- a/skills/shop-skills/skill-shop-stripe/SKILL.md +++ b/skills/shop-skills/skill-shop-stripe/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: author: SIN-Code version: 1.0.0 +required_tools: + - sin_execute + - sin_write lifecycle: external sources: - https://github.com/SIN-Shop-Center/SIN-Stripe-Bundle diff --git a/skills/shop-skills/skill-shop-tiktok/SKILL.md b/skills/shop-skills/skill-shop-tiktok/SKILL.md index c01d3d5b..b56db0d3 100644 --- a/skills/shop-skills/skill-shop-tiktok/SKILL.md +++ b/skills/shop-skills/skill-shop-tiktok/SKILL.md @@ -10,6 +10,9 @@ compatibility: metadata: author: SIN-Code version: 1.0.0 +required_tools: + - sin_execute + - sin_harvest lifecycle: external sources: - https://github.com/SIN-Shop-Center/SIN-eCommerce-Scraper-Bundle