Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions cmd/sin-code/internal/skilldist/skilldist.doc.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ without the padding still parses correctly.
| `rule` | `<home>/<InstallPath-with-<skill>>` (single file, .md or .mdc) | writes RenderBlock; replaces prior block for same skill |
| `marker` | `<home>/<InstallPath>` (single shared file, no `<skill>` 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 <name> --agent all` command runs through every
Expand Down Expand Up @@ -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.

Expand All @@ -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.
298 changes: 298 additions & 0 deletions cmd/sin-code/tui/accessibility.go
Original file line number Diff line number Diff line change
@@ -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
}
31 changes: 30 additions & 1 deletion cmd/sin-code/tui/chat_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ func handleChatSubmit(m *Model, submit chat.SubmitMsg) tea.Cmd {
" /attach-glob <pattern> Attach files matching a glob pattern\n" +
" /detach <name|index> Remove an attachment\n" +
" /clear Clear chat history\n" +
" /help Show this help message\n\n" +
" /help Show this help message\n" +
" /theme custom <path> Load custom theme from JSON\n" +
" /theme export <path> Export current theme to JSON\n\n" +
"Keys:\n" +
" Enter Send message\n" +
" Shift+Enter Insert newline\n" +
Expand All @@ -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 <path>"})
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 <path>"})
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 {
Expand Down
Loading
Loading