mirror of
https://github.com/alvinunreal/tmuxai.git
synced 2026-10-30 20:15:18 +01:00
* feat: display model reasoning (chain-of-thought) before the answer Providers like vLLM and OpenRouter return the model's chain-of-thought in a "reasoning" / "reasoning_content" field. Previously tmuxai dropped it and only showed the final answer, so the user saw just "..." while the model worked. Introduce an AIResult type carrying both Content and Reasoning, plumb it through every provider path (chat completions, Responses API, Gemini, Bedrock, Copilot), and render the reasoning dimmed above the answer so it is clearly secondary. Co-Authored-By: Claude <noreply@anthropic.com> * feat: add theme config option (light/dark) for terminal background The highlighter, inline code, and reasoning all used a dark-theme palette (monokai, dim gray, dark-blue bg) that was unreadable on a light terminal background. Add a `theme` config option (default "dark") that selects the palette before any output is rendered. - config: new `theme` field (default "dark", backward compatible) - system: SetTheme/IsLightTheme; HighlightCode picks monokailight vs monokai; processInlineCode picks light-gray bg + dark fg vs dark-blue bg + cyan fg - process_message: dimmedReasoning uses readable slate on light bg - manager: wire system.SetTheme(cfg.Theme) in NewManager - system/theme_test.go: theme switch + light/dark output tests Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: prefer shell pane for exec to avoid typing into interactive programs GetAvailablePane returned the first non-tmuxai pane as the exec pane without checking what was running in it. When the first pane ran an interactive program (e.g. copilot), commands were typed into that program instead of a shell. Now prefer a pane running a known shell (bash/zsh/fish/sh/dash/ksh), falling back to the first non-tmuxai pane only when no shell exists. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
309 lines
8.2 KiB
Go
309 lines
8.2 KiB
Go
package system
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"reflect"
|
|
"runtime"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/alecthomas/chroma"
|
|
"github.com/alecthomas/chroma/formatters"
|
|
"github.com/alecthomas/chroma/lexers"
|
|
"github.com/alecthomas/chroma/styles"
|
|
"github.com/alvinunreal/tmuxai/logger"
|
|
)
|
|
|
|
func GetProcessArgs(pid int) string {
|
|
// First check if this is a shell process
|
|
cmd := exec.Command("ps", "-p", fmt.Sprintf("%d", pid), "-o", "command=")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
logger.Error("Failed to get process command for PID %d: %v", pid, err)
|
|
return ""
|
|
}
|
|
|
|
cmdOutput := strings.TrimSpace(string(output))
|
|
|
|
// If this is a shell process (indicated by starting with "-"),
|
|
// look for child processes that might be the actual command
|
|
if strings.HasPrefix(cmdOutput, "-") {
|
|
// Find child processes
|
|
cmd = exec.Command("pgrep", "-P", fmt.Sprintf("%d", pid))
|
|
childOutput, err := cmd.Output()
|
|
if err == nil && len(childOutput) > 0 {
|
|
// Get the child PIDs
|
|
childPids := strings.Split(strings.TrimSpace(string(childOutput)), "\n")
|
|
if len(childPids) > 0 {
|
|
for _, childPid := range childPids {
|
|
if childPid == "" {
|
|
continue
|
|
}
|
|
|
|
// Get the command of this child
|
|
cmd = exec.Command("ps", "-p", childPid, "-o", "command=")
|
|
childCmdOutput, err := cmd.Output()
|
|
if err == nil {
|
|
childCmd := strings.TrimSpace(string(childCmdOutput))
|
|
// If this isn't another shell, return it
|
|
if childCmd != "" && !strings.HasPrefix(childCmd, "-") {
|
|
return childCmd
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if cmdOutput != "" && !strings.HasPrefix(cmdOutput, "-") {
|
|
// If it's not a shell and has a command, return it
|
|
return cmdOutput
|
|
}
|
|
|
|
// If we couldn't find a better command, return the original
|
|
return cmdOutput
|
|
}
|
|
|
|
// theme selects the color palette: "light" for a light terminal background,
|
|
// "dark" (default) for a dark one. Set once at startup via SetTheme, before
|
|
// any output is rendered.
|
|
var theme = "dark"
|
|
|
|
// SetTheme selects the palette ("light" or "dark"). Any other value falls
|
|
// back to "dark".
|
|
func SetTheme(t string) {
|
|
if strings.EqualFold(strings.TrimSpace(t), "light") {
|
|
theme = "light"
|
|
return
|
|
}
|
|
theme = "dark"
|
|
}
|
|
|
|
// IsLightTheme reports whether the light-background palette is active.
|
|
func IsLightTheme() bool {
|
|
return theme == "light"
|
|
}
|
|
|
|
var HighlightCode = func(language string, code string) (string, error) {
|
|
// Get the lexer for the specified language
|
|
lexer := lexers.Get(language)
|
|
if lexer == nil {
|
|
lexer = lexers.Analyse(code)
|
|
if lexer == nil {
|
|
lexer = lexers.Fallback
|
|
}
|
|
}
|
|
|
|
// Choose a style matching the terminal background. monokai is a dark
|
|
// theme (near-white default text) that is invisible on a light terminal,
|
|
// so use monokailight when the light palette is active.
|
|
var style *chroma.Style
|
|
if IsLightTheme() {
|
|
style = styles.Get("monokailight")
|
|
} else {
|
|
style = styles.Get("monokai")
|
|
}
|
|
if style == nil {
|
|
style = styles.Fallback
|
|
}
|
|
|
|
// Create a formatter for terminal output (256 colors)
|
|
formatter := formatters.Get("terminal256")
|
|
if formatter == nil {
|
|
formatter = formatters.Fallback
|
|
}
|
|
|
|
// Tokenize the code
|
|
iterator, err := lexer.Tokenise(nil, code)
|
|
if err != nil {
|
|
return "", fmt.Errorf("error tokenizing code: %w", err)
|
|
}
|
|
|
|
// Format the code into a string buffer
|
|
var buf bytes.Buffer
|
|
err = formatter.Format(&buf, style, iterator)
|
|
if err != nil {
|
|
return "", fmt.Errorf("error formatting code: %w", err)
|
|
}
|
|
|
|
return buf.String(), nil
|
|
}
|
|
|
|
// IsShellCommand checks if the given command is a shell
|
|
func IsShellCommand(command string) bool {
|
|
shellCommands := []string{
|
|
"bash", "zsh", "fish", "sh", "dash", "ksh", "csh", "tcsh",
|
|
}
|
|
return slices.Contains(shellCommands, command)
|
|
}
|
|
|
|
func IsSubShell(command string) bool {
|
|
subShellCommands := []string{
|
|
"ssh", "docker", "podman",
|
|
}
|
|
return slices.Contains(subShellCommands, command)
|
|
}
|
|
|
|
// IsShell reports whether command (a pane's pane_current_command basename) is a
|
|
// known interactive shell that tmuxai can safely type commands into.
|
|
func IsShell(command string) bool {
|
|
shells := []string{
|
|
"bash", "zsh", "fish", "sh", "dash", "ksh",
|
|
}
|
|
return slices.Contains(shells, command)
|
|
}
|
|
|
|
func GetOSDetails() string {
|
|
if runtime.GOOS == "linux" {
|
|
// Try reading /etc/os-release
|
|
content, err := os.ReadFile("/etc/os-release")
|
|
if err == nil {
|
|
lines := strings.Split(string(content), "\n")
|
|
info := make(map[string]string)
|
|
for _, line := range lines {
|
|
parts := strings.SplitN(line, "=", 2)
|
|
if len(parts) == 2 {
|
|
key := parts[0]
|
|
val := strings.Trim(parts[1], `"`)
|
|
info[key] = val
|
|
}
|
|
}
|
|
// Format without key names
|
|
osName := info["NAME"]
|
|
osVersion := info["VERSION"]
|
|
osID := info["ID"]
|
|
return fmt.Sprintf("%s %s (%s) - %s", osName, osVersion, osID, runtime.GOARCH)
|
|
}
|
|
} else if runtime.GOOS == "darwin" {
|
|
// Use sw_vers command on macOS
|
|
out, err := exec.Command("sw_vers").Output()
|
|
if err == nil {
|
|
// Process the output to create a single line without key names
|
|
outStr := string(out)
|
|
lines := strings.Split(outStr, "\n")
|
|
var osInfo []string
|
|
|
|
for _, line := range lines {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, ":", 2)
|
|
if len(parts) == 2 {
|
|
osInfo = append(osInfo, strings.TrimSpace(parts[1]))
|
|
}
|
|
}
|
|
|
|
return strings.Join(osInfo, " ") + " - " + runtime.GOARCH
|
|
}
|
|
}
|
|
return runtime.GOOS + " - " + runtime.GOARCH
|
|
}
|
|
|
|
// structToMap flattens a struct to a map[string]interface{} recursively
|
|
func StructToMap(s interface{}, prefix string) map[string]interface{} {
|
|
result := make(map[string]interface{})
|
|
val := reflect.ValueOf(s)
|
|
typ := reflect.TypeOf(s)
|
|
for i := 0; i < typ.NumField(); i++ {
|
|
field := typ.Field(i)
|
|
tag := field.Tag.Get("mapstructure")
|
|
if tag == "" {
|
|
tag = strings.ToLower(field.Name)
|
|
}
|
|
key := tag
|
|
if prefix != "" {
|
|
key = prefix + "." + tag
|
|
}
|
|
fv := val.Field(i)
|
|
if field.Type.Kind() == reflect.Struct {
|
|
nested := StructToMap(fv.Interface(), key)
|
|
for nk, nv := range nested {
|
|
result[nk] = nv
|
|
}
|
|
} else {
|
|
result[key] = fv.Interface()
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// setMapValueByDotKey sets a value in a map using dot notation keys
|
|
func SetMapValueByDotKey(m map[string]interface{}, key string, value interface{}) {
|
|
m[key] = value
|
|
}
|
|
|
|
// printMapAsYAML prints a map[string]interface{} as YAML-like output with sorted keys
|
|
func PrintMapAsYAML(m map[string]interface{}, indent int) {
|
|
// Group keys by prefix for pretty printing
|
|
type node struct {
|
|
val interface{}
|
|
children map[string]*node
|
|
}
|
|
root := &node{children: map[string]*node{}}
|
|
for k, v := range m {
|
|
parts := strings.Split(k, ".")
|
|
cur := root
|
|
for i, part := range parts {
|
|
if cur.children == nil {
|
|
cur.children = map[string]*node{}
|
|
}
|
|
if _, ok := cur.children[part]; !ok {
|
|
cur.children[part] = &node{}
|
|
}
|
|
cur = cur.children[part]
|
|
if i == len(parts)-1 {
|
|
cur.val = v
|
|
}
|
|
}
|
|
}
|
|
var printNode func(n *node, indent int)
|
|
printNode = func(n *node, indent int) {
|
|
ind := strings.Repeat(" ", indent)
|
|
|
|
// Get all keys and sort them
|
|
keys := make([]string, 0, len(n.children))
|
|
for k := range n.children {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
// Iterate through sorted keys
|
|
for _, k := range keys {
|
|
child := n.children[k]
|
|
if len(child.children) > 0 {
|
|
fmt.Printf("%s%s:", ind, k)
|
|
printNode(child, indent+1)
|
|
} else {
|
|
fmt.Printf("%s%s: %v\n", ind, k, child.val)
|
|
}
|
|
}
|
|
}
|
|
printNode(root, indent)
|
|
}
|
|
|
|
// EstimateTokenCount provides a rough estimation of token count for LLM context
|
|
// This is an approximation and not exact for all models
|
|
func EstimateTokenCount(text string) int {
|
|
// Simple approximation:
|
|
// 1. Split by whitespace for words
|
|
words := strings.Fields(text)
|
|
wordCount := len(words)
|
|
|
|
// 2. Count punctuation and special characters which might be separate tokens
|
|
punctCount := 0
|
|
for _, r := range text {
|
|
if unicode.IsPunct(r) || !unicode.IsLetter(r) && !unicode.IsNumber(r) && !unicode.IsSpace(r) {
|
|
punctCount++
|
|
}
|
|
}
|
|
|
|
// 3. Estimate: most words are 1 token, some longer words might be multiple tokens
|
|
// Typical ratio is ~0.75 tokens per word for English text + punctuation as separate tokens
|
|
estimatedTokens := int(float64(wordCount)*1.3) + punctCount
|
|
|
|
return estimatedTokens
|
|
}
|