mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
Add built-in Kimi provider support
This commit is contained in:
parent
ff1af01fd7
commit
a41cda5093
17 changed files with 549 additions and 60 deletions
|
|
@ -334,7 +334,7 @@ func PrintHelp(version string) {
|
|||
row{"zot tg ...", "short alias for telegram-bot"},
|
||||
)
|
||||
section("provider and model flags",
|
||||
row{"--provider", "provider to use (anthropic|openai|ollama)"},
|
||||
row{"--provider", "provider to use (anthropic|openai|kimi|ollama)"},
|
||||
row{"--model ID", "model id (see --list-models)"},
|
||||
row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
|
||||
row{"--base-url URL", "override provider api base url"},
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/patriceckhart/zot/internal/agent/tools"
|
||||
|
|
@ -128,13 +129,16 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
|
|||
|
||||
// User-requested provider (explicit > config > default).
|
||||
provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
|
||||
if provName != "anthropic" && provName != "openai" && provName != "ollama" {
|
||||
if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "ollama" {
|
||||
// Unknown provider (maybe removed or renamed). Fall back to
|
||||
// the first provider that has credentials, or anthropic.
|
||||
provName = "anthropic"
|
||||
if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
|
||||
provName = "openai"
|
||||
}
|
||||
if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil {
|
||||
provName = "kimi"
|
||||
}
|
||||
if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
|
||||
provName = "anthropic"
|
||||
}
|
||||
|
|
@ -166,13 +170,15 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
|
|||
// never shows a "not logged in" banner.
|
||||
userPickedProvider := args.Provider != ""
|
||||
if credErr != nil && !userPickedProvider && provName != "ollama" {
|
||||
other := "openai"
|
||||
if provName == "openai" {
|
||||
other = "anthropic"
|
||||
}
|
||||
if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
|
||||
provName = other
|
||||
cred, method, accountID, credErr = c, m, a, err
|
||||
for _, other := range []string{"anthropic", "openai", "kimi"} {
|
||||
if other == provName {
|
||||
continue
|
||||
}
|
||||
if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
|
||||
provName = other
|
||||
cred, method, accountID, credErr = c, m, a, err
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -181,6 +187,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
|
|||
switch provName {
|
||||
case "openai":
|
||||
model = "gpt-5"
|
||||
case "kimi":
|
||||
model = "kimi-for-coding"
|
||||
case "ollama":
|
||||
return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
|
||||
default:
|
||||
|
|
@ -191,9 +199,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
|
|||
// says gpt-5 but we fell back to anthropic), pick that provider's default.
|
||||
if provName != "ollama" {
|
||||
if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
|
||||
if provName == "openai" {
|
||||
switch provName {
|
||||
case "openai":
|
||||
model = "gpt-5"
|
||||
} else {
|
||||
case "kimi":
|
||||
model = "kimi-for-coding"
|
||||
default:
|
||||
model = provider.DefaultModel.ID
|
||||
}
|
||||
}
|
||||
|
|
@ -345,6 +356,8 @@ func (r Resolved) NewClient() provider.Client {
|
|||
switch r.Provider {
|
||||
case "ollama":
|
||||
return provider.NewOpenAI(r.Credential, r.BaseURL)
|
||||
case "kimi":
|
||||
return provider.NewKimiWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
|
||||
case "openai":
|
||||
if r.AuthMethod == "oauth" {
|
||||
inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
|
||||
|
|
@ -380,6 +393,8 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
|
|||
switch provName {
|
||||
case "openai":
|
||||
return provider.NewOpenAICodex(token, accountID, baseURL)
|
||||
case "kimi":
|
||||
return provider.NewKimiWithHeaders(token, baseURL, kimiCodeHeaders())
|
||||
default:
|
||||
return provider.NewAnthropicOAuth(token, baseURL)
|
||||
}
|
||||
|
|
@ -464,10 +479,37 @@ func firstNonEmpty(vals ...string) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func kimiCodeHeaders() map[string]string {
|
||||
host, _ := os.Hostname()
|
||||
if host == "" {
|
||||
host = "unknown"
|
||||
}
|
||||
deviceID := ""
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
|
||||
deviceID = strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
if deviceID == "" {
|
||||
deviceID = "zot"
|
||||
}
|
||||
return map[string]string{
|
||||
"User-Agent": "KimiCLI/1.41.0",
|
||||
"X-Msh-Platform": "kimi_cli",
|
||||
"X-Msh-Version": "1.41.0",
|
||||
"X-Msh-Device-Name": host,
|
||||
"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
|
||||
"X-Msh-Os-Version": runtime.GOOS,
|
||||
"X-Msh-Device-Id": deviceID,
|
||||
}
|
||||
}
|
||||
|
||||
func envVarName(provider string) string {
|
||||
switch provider {
|
||||
case "openai":
|
||||
return "OPENAI"
|
||||
case "kimi":
|
||||
return "KIMI"
|
||||
case "ollama":
|
||||
return "OLLAMA"
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -664,31 +664,32 @@ func runInteractive(ctx context.Context, args Args, version string) error {
|
|||
|
||||
initialCfg, _ := LoadConfig()
|
||||
iv = modes.NewInteractive(modes.InteractiveConfig{
|
||||
Terminal: term,
|
||||
Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),
|
||||
InlineImagesEnabled: initialCfg.InlineImagesEnabled,
|
||||
SettingsStore: configSettingsStore{},
|
||||
Model: r.Model,
|
||||
Provider: r.Provider,
|
||||
AuthMethod: r.AuthMethod,
|
||||
BaseURL: r.BaseURL,
|
||||
Reasoning: r.Reasoning,
|
||||
SystemPrompt: r.SystemPrompt,
|
||||
Tools: r.ToolRegistry,
|
||||
MaxSteps: r.MaxSteps,
|
||||
CWD: r.CWD,
|
||||
ZotHome: ZotHome(),
|
||||
Version: version,
|
||||
UpdateInfoChan: updateCh,
|
||||
Sandbox: sharedSandbox,
|
||||
Agent: ag,
|
||||
InitialInput: args.Prompt,
|
||||
AuthManager: mgr,
|
||||
BuildAgent: buildAgent,
|
||||
BuildAgentFor: buildAgentFor,
|
||||
Terminal: term,
|
||||
Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),
|
||||
InlineImagesEnabled: initialCfg.InlineImagesEnabled,
|
||||
SettingsStore: configSettingsStore{},
|
||||
Model: r.Model,
|
||||
Provider: r.Provider,
|
||||
AuthMethod: r.AuthMethod,
|
||||
BaseURL: r.BaseURL,
|
||||
Reasoning: r.Reasoning,
|
||||
SystemPrompt: r.SystemPrompt,
|
||||
Tools: r.ToolRegistry,
|
||||
MaxSteps: r.MaxSteps,
|
||||
CWD: r.CWD,
|
||||
ZotHome: ZotHome(),
|
||||
Version: version,
|
||||
UpdateInfoChan: updateCh,
|
||||
Sandbox: sharedSandbox,
|
||||
Agent: ag,
|
||||
InitialInput: args.Prompt,
|
||||
AuthManager: mgr,
|
||||
BuildAgent: buildAgent,
|
||||
SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
|
||||
BuildAgentFor: buildAgentFor,
|
||||
LoggedInProviders: func() []string {
|
||||
var out []string
|
||||
for _, p := range []string{"anthropic", "openai"} {
|
||||
for _, p := range []string{"anthropic", "openai", "kimi"} {
|
||||
if _, _, err := ResolveCredential(p, ""); err == nil {
|
||||
out = append(out, p)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,12 @@ func ConfigPath() string { return filepath.Join(ZotHome(), "config.json") }
|
|||
// AuthPath returns the path to auth.json.
|
||||
func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") }
|
||||
|
||||
// KimiCLIFallbackDisabledPath returns a sentinel that disables falling
|
||||
// back to the official Kimi Code CLI token after `zot /logout kimi`.
|
||||
func KimiCLIFallbackDisabledPath() string {
|
||||
return filepath.Join(ZotHome(), "kimi-cli-fallback-disabled")
|
||||
}
|
||||
|
||||
// SessionsPath returns the directory holding session files.
|
||||
func SessionsPath() string { return filepath.Join(ZotHome(), "sessions") }
|
||||
|
||||
|
|
@ -135,6 +141,13 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
|
|||
if v := os.Getenv("OPENAI_API_KEY"); v != "" {
|
||||
return v, "apikey", "", nil
|
||||
}
|
||||
case "kimi":
|
||||
if v := os.Getenv("KIMI_API_KEY"); v != "" {
|
||||
return v, "apikey", "", nil
|
||||
}
|
||||
if v := os.Getenv("MOONSHOT_API_KEY"); v != "" {
|
||||
return v, "apikey", "", nil
|
||||
}
|
||||
}
|
||||
c, err := AuthStoreFor().Load()
|
||||
if err != nil {
|
||||
|
|
@ -157,10 +170,76 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
|
|||
tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth)
|
||||
return tok.AccessToken, "oauth", tok.AccountID, nil
|
||||
}
|
||||
case "kimi":
|
||||
if c.Kimi.APIKey != "" {
|
||||
return c.Kimi.APIKey, "apikey", "", nil
|
||||
}
|
||||
if c.Kimi.OAuth != nil && c.Kimi.OAuth.AccessToken != "" {
|
||||
tok, _ := refreshIfExpired("kimi", c.Kimi.OAuth)
|
||||
return tok.AccessToken, "oauth", "", nil
|
||||
}
|
||||
if kimiCLIFallbackDisabled() {
|
||||
break
|
||||
}
|
||||
if tok := loadKimiCodeCLIToken(); tok != nil && tok.AccessToken != "" {
|
||||
tok, _ = refreshIfExpired("kimi", tok)
|
||||
return tok.AccessToken, "oauth", "", nil
|
||||
}
|
||||
}
|
||||
return "", "", "", fmt.Errorf("no credential for %s", provider)
|
||||
}
|
||||
|
||||
func kimiCLIFallbackDisabled() bool {
|
||||
_, err := os.Stat(KimiCLIFallbackDisabledPath())
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func SetKimiCLIFallbackDisabled(disabled bool) error {
|
||||
path := KimiCLIFallbackDisabledPath()
|
||||
if !disabled {
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte("disabled\n"), 0o600)
|
||||
}
|
||||
|
||||
func loadKimiCodeCLIToken() *auth.OAuthToken {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(home, ".kimi", "credentials", "kimi-code.json"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var raw struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresAt float64 `json:"expires_at"`
|
||||
Scope string `json:"scope"`
|
||||
ExpiresIn float64 `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &raw); err != nil || raw.AccessToken == "" {
|
||||
return nil
|
||||
}
|
||||
sec := int64(raw.ExpiresAt)
|
||||
nsec := int64((raw.ExpiresAt - float64(sec)) * 1e9)
|
||||
return &auth.OAuthToken{
|
||||
AccessToken: raw.AccessToken,
|
||||
RefreshToken: raw.RefreshToken,
|
||||
TokenType: raw.TokenType,
|
||||
Scope: raw.Scope,
|
||||
ClientID: auth.KimiOAuth.ClientID,
|
||||
Expiry: time.Unix(sec, nsec),
|
||||
}
|
||||
}
|
||||
|
||||
// loadOAuthToken reads the current OAuth token from auth.json for the
|
||||
// given provider. Returns nil if no token is stored.
|
||||
func loadOAuthToken(providerName string) *auth.OAuthToken {
|
||||
|
|
@ -177,6 +256,14 @@ func loadOAuthToken(providerName string) *auth.OAuthToken {
|
|||
if c.OpenAI.OAuth != nil {
|
||||
return c.OpenAI.OAuth
|
||||
}
|
||||
case "kimi":
|
||||
if c.Kimi.OAuth != nil {
|
||||
return c.Kimi.OAuth
|
||||
}
|
||||
if kimiCLIFallbackDisabled() {
|
||||
return nil
|
||||
}
|
||||
return loadKimiCodeCLIToken()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -205,6 +292,8 @@ func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthTok
|
|||
op = auth.AnthropicOAuth
|
||||
case "openai":
|
||||
op = auth.OpenAIOAuth
|
||||
case "kimi":
|
||||
op = auth.KimiOAuth
|
||||
default:
|
||||
return tok, fmt.Errorf("unknown provider %q", providerName)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,15 @@ func refreshModels() {
|
|||
all = append(all, live...)
|
||||
}
|
||||
}
|
||||
if cred, method, err := ResolveCredential("kimi", ""); err == nil && method == "apikey" {
|
||||
if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil {
|
||||
for i := range live {
|
||||
live[i].Provider = "kimi"
|
||||
live[i].Source = "live"
|
||||
}
|
||||
all = append(all, live...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -54,6 +54,10 @@ type InteractiveConfig struct {
|
|||
// the concrete provider/model in use.
|
||||
BuildAgent func() (*core.Agent, string, string, error)
|
||||
|
||||
// SetKimiCLIFallbackDisabled controls whether zot may fall back to
|
||||
// the official Kimi Code CLI token when zot has no stored Kimi token.
|
||||
SetKimiCLIFallbackDisabled func(disabled bool) error
|
||||
|
||||
// BuildAgentFor rebuilds the agent with an explicit provider/model
|
||||
// override (used by the /model picker when switching providers).
|
||||
// If providerOverride is empty, the current provider is kept.
|
||||
|
|
@ -2397,7 +2401,7 @@ func (i *Interactive) openLogoutDialog() {
|
|||
}
|
||||
|
||||
var items []logoutItem
|
||||
for _, p := range []string{"anthropic", "openai"} {
|
||||
for _, p := range []string{"anthropic", "openai", "kimi"} {
|
||||
if creds.Has(p) {
|
||||
method := creds.Method(p)
|
||||
if method == "oauth" {
|
||||
|
|
@ -2431,7 +2435,7 @@ func (i *Interactive) openLogoutDialog() {
|
|||
// is torn down so the user is forced through /login before their next
|
||||
// prompt.
|
||||
//
|
||||
// target: "anthropic" | "openai" | "all"
|
||||
// target: "anthropic" | "openai" | "kimi" | "all"
|
||||
func (i *Interactive) doLogout(target string) {
|
||||
if i.cfg.AuthManager == nil {
|
||||
i.mu.Lock()
|
||||
|
|
@ -2450,12 +2454,12 @@ func (i *Interactive) doLogout(target string) {
|
|||
var providers []string
|
||||
switch target {
|
||||
case "", "all":
|
||||
providers = []string{"anthropic", "openai"}
|
||||
case "anthropic", "openai":
|
||||
providers = []string{"anthropic", "openai", "kimi"}
|
||||
case "anthropic", "openai", "kimi":
|
||||
providers = []string{target}
|
||||
default:
|
||||
i.mu.Lock()
|
||||
i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)"
|
||||
i.statusErr = "unknown provider: " + target + " (use anthropic, openai, kimi, or all)"
|
||||
i.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
|
@ -2467,6 +2471,12 @@ func (i *Interactive) doLogout(target string) {
|
|||
errs = append(errs, p+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
|
||||
if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
|
||||
errs = append(errs, p+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
if p == i.cfg.Provider {
|
||||
clearedCurrent = true
|
||||
}
|
||||
|
|
@ -2491,6 +2501,9 @@ func (i *Interactive) doLogout(target string) {
|
|||
}
|
||||
|
||||
func (i *Interactive) startAPIKeyFlow(provider string) {
|
||||
if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
|
||||
_ = i.cfg.SetKimiCLIFallbackDisabled(false)
|
||||
}
|
||||
url, err := i.cfg.AuthManager.StartAPIKey(provider)
|
||||
if err != nil {
|
||||
i.dialog.ShowResult(false, err.Error())
|
||||
|
|
@ -2500,6 +2513,9 @@ func (i *Interactive) startAPIKeyFlow(provider string) {
|
|||
}
|
||||
|
||||
func (i *Interactive) startOAuthFlow(provider string) {
|
||||
if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
|
||||
_ = i.cfg.SetKimiCLIFallbackDisabled(false)
|
||||
}
|
||||
// Always run the manual/copy-code flow in parallel with the local
|
||||
// callback server so headless environments (docker, SSH) can paste
|
||||
// the authorization code directly without first pressing 'p'.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ type loginStep int
|
|||
const (
|
||||
loginStepClosed loginStep = iota
|
||||
loginStepMethod // pick apikey vs subscription
|
||||
loginStepProvider // pick anthropic vs openai
|
||||
loginStepProvider // pick anthropic vs openai vs kimi
|
||||
loginStepWaiting // browser open, waiting for callback
|
||||
loginStepPasteCode // user pastes the auth code here
|
||||
loginStepDone // success or error, waiting for key to dismiss
|
||||
|
|
@ -28,7 +28,7 @@ const (
|
|||
type loginDialog struct {
|
||||
step loginStep
|
||||
method string // "apikey" | "oauth"
|
||||
provider string // "anthropic" | "openai"
|
||||
provider string // "anthropic" | "openai" | "kimi"
|
||||
message string
|
||||
success bool
|
||||
url string
|
||||
|
|
@ -39,7 +39,7 @@ type loginDialog struct {
|
|||
// provider, captured when Open() runs. Rendered above the
|
||||
// method picker so the user can see whether they're already
|
||||
// logged in (and how) before starting a new flow. Keys:
|
||||
// "anthropic", "openai". Value is "apikey", "oauth", or ""
|
||||
// "anthropic", "openai", "kimi". Value is "apikey", "oauth", or ""
|
||||
// (not logged in).
|
||||
status map[string]string
|
||||
}
|
||||
|
|
@ -65,7 +65,7 @@ func (d *loginDialog) Open(zotHome string) {
|
|||
d.success = false
|
||||
d.url = ""
|
||||
d.cursor = 0
|
||||
d.status = map[string]string{"anthropic": "", "openai": ""}
|
||||
d.status = map[string]string{"anthropic": "", "openai": "", "kimi": ""}
|
||||
// Best-effort: if the auth file can't be read, treat every
|
||||
// provider as not-logged-in. The status line just won't show
|
||||
// anything useful in that case, which is fine — the user
|
||||
|
|
@ -74,6 +74,7 @@ func (d *loginDialog) Open(zotHome string) {
|
|||
if creds, err := auth.NewStore(path).Load(); err == nil {
|
||||
d.status["anthropic"] = creds.Method("anthropic")
|
||||
d.status["openai"] = creds.Method("openai")
|
||||
d.status["kimi"] = creds.Method("kimi")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -93,7 +94,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
|
|||
case loginStepMethod:
|
||||
opts := []string{
|
||||
"api key",
|
||||
"subscription (claude pro/max - chatgpt plus/pro)",
|
||||
"subscription (claude pro/max - chatgpt plus/pro - kimi code)",
|
||||
}
|
||||
lines = append(lines, frameHeader(th, "login", width))
|
||||
for _, l := range d.renderStatusLines(th) {
|
||||
|
|
@ -110,7 +111,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
|
|||
}
|
||||
lines = append(lines, frameRule(th, width))
|
||||
case loginStepProvider:
|
||||
opts := []string{"anthropic", "openai"}
|
||||
opts := []string{"anthropic", "openai", "kimi"}
|
||||
lines = append(lines, frameHeader(th, "login - "+d.method, width))
|
||||
for _, l := range d.renderStatusLines(th) {
|
||||
lines = append(lines, l)
|
||||
|
|
@ -201,6 +202,8 @@ func providerLabel(id string) string {
|
|||
return "Anthropic (Claude Pro/Max)"
|
||||
case "openai":
|
||||
return "OpenAI (ChatGPT Plus/Pro)"
|
||||
case "kimi":
|
||||
return "Kimi Code"
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
|
@ -218,7 +221,8 @@ func providerLabel(id string) string {
|
|||
func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
|
||||
anth := d.status["anthropic"]
|
||||
op := d.status["openai"]
|
||||
if anth == "" && op == "" {
|
||||
kimi := d.status["kimi"]
|
||||
if anth == "" && op == "" && kimi == "" {
|
||||
return nil
|
||||
}
|
||||
row := func(id, method string) string {
|
||||
|
|
@ -240,6 +244,7 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
|
|||
return []string{
|
||||
row("anthropic", anth),
|
||||
row("openai", op),
|
||||
row("kimi", kimi),
|
||||
"",
|
||||
}
|
||||
}
|
||||
|
|
@ -305,14 +310,14 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
|
|||
d.cursor--
|
||||
}
|
||||
case tui.KeyDown:
|
||||
if d.cursor < 1 {
|
||||
if d.cursor < 2 {
|
||||
d.cursor++
|
||||
}
|
||||
case tui.KeyEsc:
|
||||
d.Close()
|
||||
return loginDialogAction{Close: true}
|
||||
case tui.KeyEnter:
|
||||
providers := []string{"anthropic", "openai"}
|
||||
providers := []string{"anthropic", "openai", "kimi"}
|
||||
d.provider = providers[d.cursor]
|
||||
d.step = loginStepWaiting
|
||||
if d.method == "apikey" {
|
||||
|
|
|
|||
118
internal/auth/kimi.go
Normal file
118
internal/auth/kimi.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// KimiDeviceAuthorization is the OAuth 2 device-code response used by
|
||||
// the official Kimi Code CLI.
|
||||
type KimiDeviceAuthorization struct {
|
||||
UserCode string `json:"user_code"`
|
||||
DeviceCode string `json:"device_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
// RequestKimiDeviceAuthorization starts Kimi Code's device-code login.
|
||||
func RequestKimiDeviceAuthorization(ctx context.Context) (KimiDeviceAuthorization, error) {
|
||||
form := bytes.NewBufferString("client_id=" + KimiOAuth.ClientID)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "https://auth.kimi.com/api/oauth/device_authorization", form)
|
||||
if err != nil {
|
||||
return KimiDeviceAuthorization{}, err
|
||||
}
|
||||
req.Header.Set("content-type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("accept", "application/json")
|
||||
req.Header.Set("user-agent", "zot")
|
||||
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return KimiDeviceAuthorization{}, fmt.Errorf("kimi device authorization: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return KimiDeviceAuthorization{}, fmt.Errorf("kimi device authorization http %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var out KimiDeviceAuthorization
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("parse kimi device authorization: %w", err)
|
||||
}
|
||||
if out.Interval <= 0 {
|
||||
out.Interval = 5
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PollKimiDeviceToken polls until the browser/device-code login completes.
|
||||
func PollKimiDeviceToken(ctx context.Context, auth KimiDeviceAuthorization) (*OAuthToken, error) {
|
||||
interval := time.Duration(auth.Interval) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 5 * time.Second
|
||||
}
|
||||
for {
|
||||
tok, retry, err := pollKimiDeviceTokenOnce(ctx, auth.DeviceCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tok != nil {
|
||||
return tok, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(retry):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pollKimiDeviceTokenOnce(ctx context.Context, deviceCode string) (*OAuthToken, time.Duration, error) {
|
||||
form := bytes.NewBufferString("client_id=" + KimiOAuth.ClientID + "&device_code=" + deviceCode + "&grant_type=urn:ietf:params:oauth:grant-type:device_code")
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", KimiOAuth.TokenURL, form)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("content-type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("accept", "application/json")
|
||||
req.Header.Set("user-agent", "zot")
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("kimi token poll: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var raw struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn float64 `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &raw)
|
||||
if resp.StatusCode == http.StatusOK && raw.AccessToken != "" {
|
||||
return &OAuthToken{
|
||||
AccessToken: raw.AccessToken,
|
||||
RefreshToken: raw.RefreshToken,
|
||||
TokenType: raw.TokenType,
|
||||
Scope: raw.Scope,
|
||||
ClientID: KimiOAuth.ClientID,
|
||||
Expiry: time.Now().Add(time.Duration(raw.ExpiresIn) * time.Second),
|
||||
}, 0, nil
|
||||
}
|
||||
if raw.Error == "authorization_pending" || raw.Error == "slow_down" || resp.StatusCode == http.StatusBadRequest {
|
||||
return nil, 5 * time.Second, nil
|
||||
}
|
||||
if raw.Error != "" {
|
||||
return nil, 0, fmt.Errorf("kimi token poll: %s: %s", raw.Error, raw.ErrorDescription)
|
||||
}
|
||||
return nil, 0, fmt.Errorf("kimi token poll http %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
|
@ -78,8 +78,8 @@ func (m *Manager) Close() {
|
|||
|
||||
// StartAPIKey launches the API-key login flow.
|
||||
func (m *Manager) StartAPIKey(provider string) (string, error) {
|
||||
if provider != "anthropic" && provider != "openai" {
|
||||
return "", fmt.Errorf("provider must be anthropic or openai")
|
||||
if provider != "anthropic" && provider != "openai" && provider != "kimi" {
|
||||
return "", fmt.Errorf("provider must be anthropic, openai, or kimi")
|
||||
}
|
||||
if err := m.ensureKeyServer(); err != nil {
|
||||
return "", err
|
||||
|
|
@ -125,6 +125,9 @@ func (m *Manager) consumeKeyServerResults() {
|
|||
// Only one oauth flow may be in progress at a time (because the
|
||||
// callback port is fixed per provider and re-used by the official CLIs).
|
||||
func (m *Manager) StartOAuth(provider string) (string, error) {
|
||||
if provider == "kimi" {
|
||||
return m.StartKimiDeviceOAuth()
|
||||
}
|
||||
var op OAuthProvider
|
||||
switch provider {
|
||||
case "anthropic":
|
||||
|
|
@ -132,7 +135,7 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
|
|||
case "openai":
|
||||
op = OpenAIOAuth
|
||||
default:
|
||||
return "", fmt.Errorf("provider must be anthropic or openai")
|
||||
return "", fmt.Errorf("provider must be anthropic, openai, or kimi")
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
|
|
@ -203,11 +206,49 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *Callback
|
|||
m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"})
|
||||
}
|
||||
|
||||
// StartKimiDeviceOAuth starts Kimi Code's device-code subscription login.
|
||||
func (m *Manager) StartKimiDeviceOAuth() (string, error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m.mu.Lock()
|
||||
if m.oauthCancel != nil {
|
||||
m.oauthCancel()
|
||||
}
|
||||
m.oauthCtx = ctx
|
||||
m.oauthCancel = cancel
|
||||
m.mu.Unlock()
|
||||
|
||||
dev, err := RequestKimiDeviceAuthorization(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
url := dev.VerificationURIComplete
|
||||
go m.maybeOpen(url)
|
||||
m.emit(Event{Kind: "started", Provider: "kimi", Method: "oauth", URL: url})
|
||||
go func() {
|
||||
tok, err := PollKimiDeviceToken(ctx, dev)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
m.emit(Event{Kind: "error", Provider: "kimi", Method: "oauth", Message: err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := m.store.SetOAuth("kimi", *tok); err != nil {
|
||||
m.emit(Event{Kind: "error", Provider: "kimi", Method: "oauth", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
m.emit(Event{Kind: "success", Provider: "kimi", Method: "oauth"})
|
||||
}()
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// StartManualOAuth begins an OAuth flow but does NOT start a local
|
||||
// callback server or open a browser. The returned URL is shown to the
|
||||
// user so they can complete the authorization on another device; the
|
||||
// resulting code is pasted back via CompleteManualOAuth.
|
||||
func (m *Manager) StartManualOAuth(provider string) (string, error) {
|
||||
if provider == "kimi" {
|
||||
return m.StartKimiDeviceOAuth()
|
||||
}
|
||||
var op OAuthProvider
|
||||
switch provider {
|
||||
case "anthropic":
|
||||
|
|
@ -215,7 +256,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
|
|||
case "openai":
|
||||
op = OpenAIOAuth
|
||||
default:
|
||||
return "", fmt.Errorf("provider must be anthropic or openai")
|
||||
return "", fmt.Errorf("provider must be anthropic, openai, or kimi")
|
||||
}
|
||||
|
||||
pkce, err := NewPKCE()
|
||||
|
|
|
|||
|
|
@ -111,6 +111,14 @@ var (
|
|||
"originator": "zot",
|
||||
},
|
||||
}
|
||||
|
||||
// Kimi Code subscription: used by the official Kimi Code CLI.
|
||||
// Kimi uses OAuth 2 device-code flow instead of a loopback callback.
|
||||
KimiOAuth = OAuthProvider{
|
||||
Name: "kimi",
|
||||
TokenURL: "https://auth.kimi.com/api/oauth/token",
|
||||
ClientID: "17e5f671-d194-4dfb-9706-5516cb48c098",
|
||||
}
|
||||
)
|
||||
|
||||
// PKCE holds a verifier/challenge pair.
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error {
|
|||
return err
|
||||
}
|
||||
req.Header.Set("authorization", "Bearer "+key)
|
||||
case "kimi":
|
||||
req, err = http.NewRequestWithContext(ctx, "GET", "https://api.kimi.com/coding/v1/models", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("authorization", "Bearer "+key)
|
||||
default:
|
||||
return fmt.Errorf("unknown provider %q", provider)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,8 +111,8 @@ func (s *Server) handleAPIKey(w http.ResponseWriter, r *http.Request) {
|
|||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
provider := r.URL.Query().Get("provider")
|
||||
if provider != "anthropic" && provider != "openai" {
|
||||
http.Error(w, "provider must be anthropic or openai", http.StatusBadRequest)
|
||||
if provider != "anthropic" && provider != "openai" && provider != "kimi" {
|
||||
http.Error(w, "provider must be anthropic, openai, or kimi", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tpl.ExecuteTemplate(w, "apikey", map[string]any{"Provider": provider})
|
||||
|
|
@ -223,13 +223,14 @@ var tpl = template.Must(template.New("index").Parse(`<!doctype html><html lang="
|
|||
` + logoTag + `
|
||||
<h1><span class="zot">zot</span> login</h1>
|
||||
<hr class="rule">
|
||||
<p>paste an api key for anthropic or openai. <span class="zot">zot</span> probes the provider once, then saves the key to <span class="mono">~/Library/Application Support/zot/auth.json</span>.</p>
|
||||
<p>paste an api key for anthropic, openai, or kimi. <span class="zot">zot</span> probes the provider once, then saves the key to <span class="mono">~/Library/Application Support/zot/auth.json</span>.</p>
|
||||
<p>
|
||||
<a href="/apikey?provider=anthropic">anthropic api key →</a><br>
|
||||
<a href="/apikey?provider=openai">openai api key →</a>
|
||||
<a href="/apikey?provider=openai">openai api key →</a><br>
|
||||
<a href="/apikey?provider=kimi">kimi api key →</a>
|
||||
</p>
|
||||
<hr class="rule">
|
||||
<p class="muted">for a subscription login (claude pro/max - chatgpt plus/pro), close this tab and run /login inside <span class="zot">zot</span>.</p>
|
||||
<p class="muted">for a subscription login (claude pro/max - chatgpt plus/pro - kimi code), close this tab and run /login inside <span class="zot">zot</span>.</p>
|
||||
</body></html>`))
|
||||
|
||||
func init() {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
type Credentials struct {
|
||||
Anthropic ProviderCreds `json:"anthropic,omitempty"`
|
||||
OpenAI ProviderCreds `json:"openai,omitempty"`
|
||||
Kimi ProviderCreds `json:"kimi,omitempty"`
|
||||
}
|
||||
|
||||
// ProviderCreds holds credentials for a single provider. Only one of
|
||||
|
|
@ -80,6 +81,8 @@ func (c *Credentials) get(provider string) *ProviderCreds {
|
|||
return &c.Anthropic
|
||||
case "openai":
|
||||
return &c.OpenAI
|
||||
case "kimi":
|
||||
return &c.Kimi
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,13 @@ var Catalog = []Model{
|
|||
PriceInput: 15.00, PriceOutput: 75.00, PriceCacheRead: 1.50, PriceCacheWrite: 18.75,
|
||||
},
|
||||
|
||||
// ---- Kimi / Kimi Code ----
|
||||
{
|
||||
Provider: "kimi", ID: "kimi-for-coding", DisplayName: "Kimi-k2.6",
|
||||
ContextWindow: 262144, MaxOutput: 32000, Reasoning: true,
|
||||
BaseURL: "https://api.kimi.com/coding/v1",
|
||||
},
|
||||
|
||||
// ---- OpenAI / GPT-5 family ----
|
||||
{
|
||||
Provider: "openai", ID: "gpt-5", DisplayName: "GPT-5",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ const openaiDefaultBaseURL = "https://api.openai.com"
|
|||
type openaiClient struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
name string
|
||||
oauth bool // when true, apiKey actually holds an OAuth access token
|
||||
headers map[string]string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
|
|
@ -29,6 +31,7 @@ func NewOpenAI(apiKey, baseURL string) Client {
|
|||
return &openaiClient{
|
||||
apiKey: apiKey,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
name: "openai",
|
||||
http: &http.Client{Timeout: 0},
|
||||
}
|
||||
}
|
||||
|
|
@ -42,12 +45,38 @@ func NewOpenAIOAuth(accessToken, baseURL string) Client {
|
|||
return &openaiClient{
|
||||
apiKey: accessToken,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
name: "openai",
|
||||
oauth: true,
|
||||
http: &http.Client{Timeout: 0},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *openaiClient) Name() string { return "openai" }
|
||||
// NewKimi creates a Kimi/Moonshot client. Kimi's chat API is OpenAI-compatible.
|
||||
func NewKimi(apiKey, baseURL string) Client {
|
||||
return NewKimiWithHeaders(apiKey, baseURL, nil)
|
||||
}
|
||||
|
||||
// NewKimiWithHeaders creates a Kimi/Moonshot client with extra headers.
|
||||
// Subscription tokens from Kimi Code need the official CLI's X-Msh-* headers.
|
||||
func NewKimiWithHeaders(apiKey, baseURL string, headers map[string]string) Client {
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.kimi.com/coding/v1"
|
||||
}
|
||||
return &openaiClient{
|
||||
apiKey: apiKey,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
name: "kimi",
|
||||
headers: headers,
|
||||
http: &http.Client{Timeout: 0},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *openaiClient) Name() string {
|
||||
if c.name != "" {
|
||||
return c.name
|
||||
}
|
||||
return "openai"
|
||||
}
|
||||
|
||||
// ---- wire types ----
|
||||
|
||||
|
|
@ -285,6 +314,10 @@ func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} {
|
|||
// ---- streaming ----
|
||||
|
||||
func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
|
||||
apiPath := "/v1/chat/completions"
|
||||
if strings.HasSuffix(c.baseURL, "/v1") {
|
||||
apiPath = "/chat/completions"
|
||||
}
|
||||
wire, err := c.buildRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -293,13 +326,16 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/chat/completions", bytes.NewReader(body))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+apiPath, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("content-type", "application/json")
|
||||
httpReq.Header.Set("accept", "text/event-stream")
|
||||
httpReq.Header.Set("authorization", "Bearer "+c.apiKey)
|
||||
for k, v := range c.headers {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(httpReq)
|
||||
if err != nil {
|
||||
|
|
@ -321,7 +357,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
|
|||
defer resp.Body.Close()
|
||||
|
||||
model, _ := FindModel("", req.Model)
|
||||
out <- EventStart{Model: req.Model, Provider: "openai"}
|
||||
out <- EventStart{Model: req.Model, Provider: c.Name()}
|
||||
|
||||
raw := make(chan sseEvent, 16)
|
||||
go readSSE(resp.Body, raw)
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ func LoadUserModels(path string) []Model {
|
|||
normalized = "openai"
|
||||
case "anthropic-messages":
|
||||
normalized = "anthropic"
|
||||
case "moonshot", "moonshot-ai", "kimi-code":
|
||||
normalized = "kimi"
|
||||
}
|
||||
|
||||
for _, um := range prov.Models {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import (
|
|||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/patriceckhart/zot/internal/provider"
|
||||
)
|
||||
|
||||
|
|
@ -1440,6 +1442,7 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string {
|
|||
// in muted type, everything else on the default tool-output color.
|
||||
// Called from renderToolText when the first line starts with "$ ".
|
||||
func (v *View) renderBashResult(lines []string, width, defaultColor int) []string {
|
||||
lines = normalizeBashOutputLines(lines)
|
||||
// Identify the footer line (exit + timing). The bash tool writes
|
||||
// it as the last non-empty line of the result.
|
||||
footerIdx := -1
|
||||
|
|
@ -1477,6 +1480,108 @@ func (v *View) renderBashResult(lines []string, width, defaultColor int) []strin
|
|||
return out
|
||||
}
|
||||
|
||||
// normalizeBashOutputLines turns arbitrary terminal output into plain,
|
||||
// box-safe rows before wrapping. Unlike read/write/edit output, bash
|
||||
// stdout/stderr may contain tabs, carriage returns, ANSI/OSC escapes,
|
||||
// and other C0 controls from subprocesses. If those reach the bordered
|
||||
// tool box, the width calculator can undercount what the terminal will
|
||||
// draw and the right edge appears broken. Keep printable text, expand
|
||||
// tabs to spaces, split carriage-return progress rows, and drop escape
|
||||
// / control sequences.
|
||||
func normalizeBashOutputLines(lines []string) []string {
|
||||
var out []string
|
||||
for _, line := range lines {
|
||||
for _, part := range strings.Split(strings.ReplaceAll(line, "\r", "\n"), "\n") {
|
||||
out = append(out, normalizeBashOutputLine(part))
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{""}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeBashOutputLine(s string) string {
|
||||
var b strings.Builder
|
||||
col := 0
|
||||
for i := 0; i < len(s); {
|
||||
c := s[i]
|
||||
if c == 0x1b { // ESC: strip CSI/OSC/DCS and simple escapes.
|
||||
i = skipEscapeSequence(s, i)
|
||||
continue
|
||||
}
|
||||
r, size := utf8.DecodeRuneInString(s[i:])
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
switch r {
|
||||
case '\t':
|
||||
spaces := 8 - (col % 8)
|
||||
if spaces == 0 {
|
||||
spaces = 8
|
||||
}
|
||||
b.WriteString(strings.Repeat(" ", spaces))
|
||||
col += spaces
|
||||
case '\b':
|
||||
// Backspace-overstrike output is common in spinners/progress bars.
|
||||
// Dropping it is safer than moving the TUI cursor backwards.
|
||||
case '\n', '\r':
|
||||
// Already split by normalizeBashOutputLines.
|
||||
default:
|
||||
if r < 0x20 || r == 0x7f {
|
||||
// Drop non-printing controls.
|
||||
break
|
||||
}
|
||||
b.WriteRune(r)
|
||||
col += runewidth.RuneWidth(r)
|
||||
}
|
||||
i += size
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func skipEscapeSequence(s string, i int) int {
|
||||
if i >= len(s) || s[i] != 0x1b {
|
||||
return i + 1
|
||||
}
|
||||
if i+1 >= len(s) {
|
||||
return len(s)
|
||||
}
|
||||
switch s[i+1] {
|
||||
case '[': // CSI: ESC [ ... final byte 0x40-0x7e
|
||||
j := i + 2
|
||||
for j < len(s) {
|
||||
c := s[j]
|
||||
j++
|
||||
if c >= 0x40 && c <= 0x7e {
|
||||
break
|
||||
}
|
||||
}
|
||||
return j
|
||||
case ']': // OSC: ESC ] ... BEL or ST
|
||||
return skipStringEscape(s, i+2)
|
||||
case 'P', '_', '^', 'X': // DCS/APC/PM/SOS: ESC P ... ST, etc.
|
||||
return skipStringEscape(s, i+2)
|
||||
default:
|
||||
// Two-byte escape (cursor save/restore, charset select, etc.).
|
||||
return i + 2
|
||||
}
|
||||
}
|
||||
|
||||
func skipStringEscape(s string, i int) int {
|
||||
for i < len(s) {
|
||||
if s[i] == 0x07 { // BEL
|
||||
return i + 1
|
||||
}
|
||||
if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' { // ST
|
||||
return i + 2
|
||||
}
|
||||
i++
|
||||
}
|
||||
return len(s)
|
||||
}
|
||||
|
||||
// looksLikeUnifiedDiff reports whether text is a context diff as
|
||||
// emitted by the edit tool: rows start with '+', '-', ' ', or
|
||||
// literal "..." (context-break marker). The presence of at least
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue