Skip to content
Draft
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
3 changes: 2 additions & 1 deletion cmd/kai/log_ingest.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"strings"
"time"

"github.com/kaicontext/kai-engine/kaipath"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -655,7 +656,7 @@ func stableIngestCommand() string {
// binary if it's not in a temp dir, then a bare "kai" (PATH lookup).
func stableKaiPath() string {
if home, err := os.UserHomeDir(); err == nil {
if p := filepath.Join(home, ".kai", "bin", "kai"); isExecutableFile(p) {
if p := kaipath.UserPath(home, "bin", "kai"); isExecutableFile(p) {
return p
}
}
Expand Down
18 changes: 8 additions & 10 deletions cmd/kai/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import (
"encoding/xml"
"fmt"
"io"
"kai/internal/config"
"kai/internal/kitlauncher"
tuierrors "kai/internal/tui/errors"
"net/http"
"net/url"
"os"
Expand All @@ -29,14 +32,8 @@ import (
"unicode"

"github.com/bmatcuk/doublestar/v4"
"github.com/mattn/go-isatty"
"github.com/sergi/go-diff/diffmatchpatch"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"

"github.com/kaicontext/kai-core/diff"
"github.com/kaicontext/kai-core/merge"

"github.com/kaicontext/kai-engine/ai"
"github.com/kaicontext/kai-engine/authorship"
"github.com/kaicontext/kai-engine/classify"
Expand Down Expand Up @@ -64,9 +61,10 @@ import (
"github.com/kaicontext/kai-engine/telemetry"
"github.com/kaicontext/kai-engine/util"
"github.com/kaicontext/kai-engine/workspace"
"kai/internal/config"
"kai/internal/kitlauncher"
tuierrors "kai/internal/tui/errors"
"github.com/mattn/go-isatty"
"github.com/sergi/go-diff/diffmatchpatch"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)

const (
Expand Down Expand Up @@ -168,7 +166,7 @@ var verbose bool
var authLoginToken string

// updateCheckFile is the path to the cached update check result.
var updateCheckFile = filepath.Join(os.Getenv("HOME"), ".kai", "update-check.json")
var updateCheckFile = kaipath.UserPath(os.Getenv("HOME"), "update-check.json")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pre-existing os.Getenv("HOME") (unchanged by this PR) yields a relative path on Windows where HOME is unset; not this change's bug, just not fixed by it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateCheckFile now captures KAI_DATA_DIR at package-init time, freezing the cache path for the process lifetime; runtime re-homing won't move it, and no test asserts it follows the env var.


type updateCheck struct {
LatestVersion string `json:"latest_version"`
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ require (
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kaicontext/kai-engine v0.6.56
github.com/kaicontext/kai-engine v0.6.59-0.20260908192613-5ab1b102fc2f
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ github.com/kaicontext/kai-core v0.1.0 h1:jDnRes5rSNtXUPJ7+K510SLUTpqGbIoff4eNhcc
github.com/kaicontext/kai-core v0.1.0/go.mod h1:N46/skG6wLkqDUarqnu9VamofK3rgLVA3sfsd6ltfIg=
github.com/kaicontext/kai-engine v0.6.56 h1:TOMBTzEijt6VMKjL/mlhwS3NinDemK9Crj8QeqnCAeU=
github.com/kaicontext/kai-engine v0.6.56/go.mod h1:a7vLmfPifbByHuIy9npyThoEM4L7s3bHgb6eBEM1+x0=
github.com/kaicontext/kai-engine v0.6.59-0.20260908191034-bdf8837ffaf2 h1:/AZcHhhvimdkH8XAYdPzI1nxB2zlazecbYdWukeUfsk=
github.com/kaicontext/kai-engine v0.6.59-0.20260908191034-bdf8837ffaf2/go.mod h1:9/NGcS0pyBvkweuafe8zZozETV2qVxXbNh6u2jMtBJs=
github.com/kaicontext/kai-engine v0.6.59-0.20260908192613-5ab1b102fc2f h1:/Yo+axEITfrwZAsJm3PF2ydEoqLkcnM/W/aGW8ARNxk=
github.com/kaicontext/kai-engine v0.6.59-0.20260908192613-5ab1b102fc2f/go.mod h1:9/NGcS0pyBvkweuafe8zZozETV2qVxXbNh6u2jMtBJs=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
Expand Down
4 changes: 3 additions & 1 deletion internal/kitlauncher/kitlauncher.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import (
"runtime"
"strings"
"time"

"github.com/kaicontext/kai-engine/kaipath"
)

// Sentinel errors classify resolveKitPath / install outcomes so callers
Expand Down Expand Up @@ -151,7 +153,7 @@ func Default() *Launcher {
// downloaded to ~/.kai/bin and shadowing it.
binDir := os.Getenv("KAI_INSTALL_DIR")
if binDir == "" {
binDir = filepath.Join(home, ".kai", "bin")
binDir = kaipath.UserPath(home, "bin")
}
return &Launcher{
BaseURL: "https://app.kaicontext.com/dl/",
Expand Down
21 changes: 21 additions & 0 deletions internal/kitlauncher/kitlauncher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -797,3 +797,24 @@ func TestResolveKitPath_EnvOverride(t *testing.T) {
t.Fatal("a broken override must error, not fall back")
}
}

func TestDefaultDataDirectory(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("KAI_DATA_DIR", "")
t.Setenv("KAI_INSTALL_DIR", "")
if got := Default().BinDir; got != filepath.Join(home, ".kai", "bin") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the default-path leg passes identically on the old hardcoded code; only the KAI_DATA_DIR leg could distinguish old from new, and its power depends on the unread external kaipath.UserPath contract, so the regression is not verified.

t.Fatal(got)
}
dev := t.TempDir()
t.Setenv("KAI_DATA_DIR", dev)
if got := Default().BinDir; got != filepath.Join(dev, "bin") {
t.Fatal(got)
}
bundle := t.TempDir()
t.Setenv("KAI_INSTALL_DIR", bundle)
if got := Default().BinDir; got != bundle {
t.Fatalf("bundle override lost: %s", got)
}
}
61 changes: 30 additions & 31 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ package tui
import (
"context"
"fmt"
"kai/api/graph"
"kai/api/memstat"
"kai/api/projects"
"kai/api/provider"
"kai/api/watcher"
"kai/internal/tui/views"
"log"
"os"
"os/signal"
Expand All @@ -22,13 +28,7 @@ import (

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"

"kai/api/graph"
"kai/api/provider"
"kai/api/memstat"
"kai/api/projects"
"kai/internal/tui/views"
"kai/api/watcher"
"github.com/kaicontext/kai-engine/kaipath"
)

// Options configures a TUI session. The TUI reads from a live graph
Expand Down Expand Up @@ -589,10 +589,10 @@ type model struct {
width int
height int

repl views.REPL
gate views.Gate
sync views.Sync
status views.StatusBar
repl views.REPL
gate views.Gate
sync views.Sync
status views.StatusBar
syncCh <-chan views.SyncEvent
chatCh <-chan views.ChatActivityEvent
hostProcCh <-chan views.HostProcEvent
Expand Down Expand Up @@ -633,11 +633,11 @@ func initialModel(opts Options, syncCh <-chan views.SyncEvent, chatCh <-chan vie
// non-primary roots in a multi-root workspace.
gate.SetProjects(opts.Projects)
return model{
opts: opts,
repl: views.NewREPLWithSession(opts.Binary, opts.WorkDir, opts.Planner, opts.ResumeSessionID),
gate: gate,
sync: s,
status: status,
opts: opts,
repl: views.NewREPLWithSession(opts.Binary, opts.WorkDir, opts.Planner, opts.ResumeSessionID),
gate: gate,
sync: s,
status: status,
syncCh: syncCh,
chatCh: chatCh,
hostProcCh: hostProcCh,
Expand Down Expand Up @@ -681,7 +681,7 @@ func (m model) Init() tea.Cmd {
// short transient error line in the REPL so the user sees that
// SOMETHING went wrong without seeing the stack.
//
// Stack traces are written to ~/.kai/tui-panic.log so a developer
// Stack traces are written to the user Kai state directory (KAI_DATA_DIR or ~/.kai) so a developer
// can post-mortem without disturbing the user's session.
func (m model) Update(msg tea.Msg) (resultModel tea.Model, resultCmd tea.Cmd) {
defer func() {
Expand All @@ -692,7 +692,7 @@ func (m model) Update(msg tea.Msg) (resultModel tea.Model, resultCmd tea.Cmd) {
// state stays consistent; only the error display is
// added.
m.repl = m.repl.AppendSystemError(fmt.Sprintf(
"internal error suppressed (see ~/.kai/tui-panic.log) — continuing"))
"internal error suppressed (see the user Kai state directory (KAI_DATA_DIR or ~/.kai)) — continuing"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the user-facing message dropped the tui-panic.log filename and claims "KAI_DATA_DIR or ~/.kai", but logTUIPanic writes to the project's KaiDir first and only falls back to UserPath(home); users are sent to the wrong place with no file to look for.

resultModel = m
resultCmd = nil
}
Expand Down Expand Up @@ -973,8 +973,7 @@ func (m *model) setFocus(f focus) {
}
}


// logTUIPanic appends a stack trace to ~/.kai/tui-panic.log so a
// logTUIPanic appends a stack trace to the user Kai state directory (KAI_DATA_DIR or ~/.kai) so a
// developer can post-mortem the panic that just got swallowed by
// the recover in Update. Best-effort: failing to open the log
// must not itself panic. Falls back to UserHomeDir when the
Expand All @@ -986,7 +985,7 @@ func logTUIPanic(m model, msg tea.Msg, panicVal any) {
}
if dir == "" {
if home, err := os.UserHomeDir(); err == nil {
dir = filepath.Join(home, ".kai")
dir = kaipath.UserPath(home)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kaipath.UserPath(home) is the only zero-variadic call; whether it returns the base ~/.kai depends on the helper's contract, unverified, and if it assumes ≥1 component the panic-log fallback silently breaks.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kaipath.UserPath(home) is called with zero path elements; if the external contract requires a non-empty tail, the panic log silently lands in the wrong directory, and this call site has no test coverage.

}
}
if dir == "" {
Expand All @@ -1005,7 +1004,6 @@ func logTUIPanic(m model, msg tea.Msg, panicVal any) {
_, _ = f.Write(debug.Stack())
}


// firstNonEmptyLine returns the first non-empty trimmed line of s,
// restoreTerminalForSafety emits the ANSI sequences that revert the
// modes Bubble Tea sets (alt-screen, mouse tracking, bracketed paste,
Expand All @@ -1018,15 +1016,16 @@ func logTUIPanic(m model, msg tea.Msg, panicVal any) {
// to stderr.
//
// Sequences:
// 1049l exit alternate screen buffer (return to the user's
// normal scrollback)
// 25h show cursor (Bubble Tea hides it during the run)
// 1000l disable basic mouse tracking
// 1002l disable cell-motion mouse tracking (what
// WithMouseCellMotion turned on)
// 1003l disable any-event mouse tracking (defensive)
// 2004l disable bracketed-paste mode
// ?7h re-enable line wrap (the default; some TUIs disable it)
//
// 1049l exit alternate screen buffer (return to the user's
// normal scrollback)
// 25h show cursor (Bubble Tea hides it during the run)
// 1000l disable basic mouse tracking
// 1002l disable cell-motion mouse tracking (what
// WithMouseCellMotion turned on)
// 1003l disable any-event mouse tracking (defensive)
// 2004l disable bracketed-paste mode
// ?7h re-enable line wrap (the default; some TUIs disable it)
func restoreTerminalForSafety() {
const reset = "\x1b[?1049l\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?2004l\x1b[?7h"
fmt.Fprint(os.Stderr, reset)
Expand Down