Skip to content
116 changes: 116 additions & 0 deletions internal/runtime/session_logs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package runtime

import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)

const logViewerPersistDir = "log-viewer"

// SessionLogEntry 描述会话维度的日志查看器持久化条目。
type SessionLogEntry struct {
Timestamp time.Time `json:"timestamp"`
Level string `json:"level"`
Source string `json:"source"`
Message string `json:"message"`
}

// LoadSessionLogEntries 按会话 ID 读取日志查看器持久化数据。
func (s *Service) LoadSessionLogEntries(ctx context.Context, sessionID string) ([]SessionLogEntry, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
path, err := s.sessionLogEntriesPath(sessionID)
if err != nil || path == "" {
return nil, err
}
payload, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("runtime: read session log entries: %w", err)
}
entries := make([]SessionLogEntry, 0)
if err := json.Unmarshal(payload, &entries); err != nil {
return nil, fmt.Errorf("runtime: decode session log entries: %w", err)
}
return append([]SessionLogEntry(nil), entries...), nil
}

// SaveSessionLogEntries 将日志查看器条目写入会话维度持久化存储。
func (s *Service) SaveSessionLogEntries(ctx context.Context, sessionID string, entries []SessionLogEntry) error {
if err := ctx.Err(); err != nil {
return err
}
path, err := s.sessionLogEntriesPath(sessionID)
if err != nil || path == "" {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("runtime: ensure session log directory: %w", err)
}
payload, err := json.Marshal(entries)
if err != nil {
return fmt.Errorf("runtime: encode session log entries: %w", err)
}
if err := os.WriteFile(path, payload, 0o600); err != nil {
return fmt.Errorf("runtime: write session log entries: %w", err)
}
return nil
}

// sessionLogEntriesPath 生成会话日志文件路径,并确保命名稳定且避免会话 ID 冲突。
func (s *Service) sessionLogEntriesPath(sessionID string) (string, error) {
normalizedSessionID := strings.TrimSpace(sessionID)
if normalizedSessionID == "" {
return "", nil
}
if s == nil || s.configManager == nil {
return "", errors.New("runtime: config manager is not initialized")
}
baseDir := strings.TrimSpace(s.configManager.BaseDir())
if baseDir == "" {
return "", errors.New("runtime: config base directory is empty")
}
sum := sha256.Sum256([]byte(normalizedSessionID))
fileName := fmt.Sprintf("%s_%s.json", sanitizeSessionLogPrefix(normalizedSessionID), hex.EncodeToString(sum[:8]))
return filepath.Join(baseDir, logViewerPersistDir, fileName), nil
}

// sanitizeSessionLogPrefix 生成可读前缀,便于排查文件,同时不参与唯一性判定。
func sanitizeSessionLogPrefix(sessionID string) string {
var b strings.Builder
for _, r := range sessionID {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '_' || r == '-':
b.WriteRune(r)
default:
if b.Len() > 0 {
b.WriteByte('_')
}
}
if b.Len() >= 24 {
break
}
}
prefix := strings.Trim(b.String(), "_")
if prefix == "" {
return "session"
}
return prefix
}
114 changes: 114 additions & 0 deletions internal/runtime/session_logs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package runtime

import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"

"neo-code/internal/config"
)

func newSessionLogTestService(t *testing.T) *Service {
t.Helper()
cfg := config.StaticDefaults()
cfg.Workdir = t.TempDir()
manager := config.NewManager(config.NewLoader(cfg.Workdir, cfg))
if _, err := manager.Load(context.Background()); err != nil {
t.Fatalf("Load() error = %v", err)
}
return &Service{configManager: manager}
}

func TestSessionLogEntriesPathAndSanitizePrefix(t *testing.T) {
service := newSessionLogTestService(t)

pathA, err := service.sessionLogEntriesPath("a:b")
if err != nil || pathA == "" {
t.Fatalf("sessionLogEntriesPath(a:b) err=%v path=%q", err, pathA)
}
pathB, err := service.sessionLogEntriesPath("a/b")
if err != nil || pathB == "" {
t.Fatalf("sessionLogEntriesPath(a/b) err=%v path=%q", err, pathB)
}
if pathA == pathB {
t.Fatalf("expected different file names for potential sanitize collision ids, got %q", pathA)
}
if got := sanitizeSessionLogPrefix(" /a:b?c* "); got == "" {
t.Fatal("expected sanitizeSessionLogPrefix to produce fallback prefix")
}
if got := sanitizeSessionLogPrefix("___"); got != "session" {
t.Fatalf("sanitizeSessionLogPrefix(___)=%q, want session", got)
}
}

func TestLoadAndSaveSessionLogEntries(t *testing.T) {
service := newSessionLogTestService(t)
sessionID := "session-one"
source := []SessionLogEntry{
{Timestamp: time.Unix(1700000000, 0), Level: "info", Source: "tool", Message: "ok"},
}

if err := service.SaveSessionLogEntries(context.Background(), sessionID, source); err != nil {
t.Fatalf("SaveSessionLogEntries() error = %v", err)
}
loaded, err := service.LoadSessionLogEntries(context.Background(), sessionID)
if err != nil {
t.Fatalf("LoadSessionLogEntries() error = %v", err)
}
if len(loaded) != 1 || loaded[0].Message != "ok" {
t.Fatalf("unexpected loaded entries: %+v", loaded)
}

missing, err := service.LoadSessionLogEntries(context.Background(), "missing-session")
if err != nil {
t.Fatalf("LoadSessionLogEntries(missing) error = %v", err)
}
if len(missing) != 0 {
t.Fatalf("expected missing session to return empty entries, got %+v", missing)
}
}

func TestSessionLogEntriesErrorBranches(t *testing.T) {
service := newSessionLogTestService(t)

if err := service.SaveSessionLogEntries(context.Background(), "", nil); err != nil {
t.Fatalf("SaveSessionLogEntries(blank) should skip, got err=%v", err)
}
if _, err := service.LoadSessionLogEntries(context.Background(), ""); err != nil {
t.Fatalf("LoadSessionLogEntries(blank) should skip, got err=%v", err)
}

path, err := service.sessionLogEntriesPath("bad-json")
if err != nil {
t.Fatalf("sessionLogEntriesPath() error = %v", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(path, []byte("{invalid"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if _, err := service.LoadSessionLogEntries(context.Background(), "bad-json"); err == nil {
t.Fatal("expected invalid json load error")
}

brokenService := &Service{configManager: nil}
if err := brokenService.SaveSessionLogEntries(context.Background(), "id", nil); err == nil {
t.Fatal("expected save error when config manager is nil")
}
if _, err := brokenService.LoadSessionLogEntries(context.Background(), "id"); err == nil {
t.Fatal("expected load error when config manager is nil")
}

cancelled, cancel := context.WithCancel(context.Background())
cancel()
if err := service.SaveSessionLogEntries(cancelled, "id", nil); !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled error on save, got %v", err)
}
if _, err := service.LoadSessionLogEntries(cancelled, "id"); !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled error on load, got %v", err)
}
}
19 changes: 19 additions & 0 deletions internal/tui/core/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ import (
tuistate "neo-code/internal/tui/state"
)

type logEntry struct {
Timestamp time.Time
Level string
Source string
Message string
}

type panel = tuistate.Panel

const (
Expand Down Expand Up @@ -95,6 +102,7 @@ type appRuntimeState struct {
codeCopyBlocks map[int]string
pendingCopyID int
deferredEventCmd tea.Cmd
deferredLogPersistCmd tea.Cmd
nowFn func() time.Time
lastInputEditAt time.Time
lastPasteLikeAt time.Time
Expand Down Expand Up @@ -122,6 +130,17 @@ type appRuntimeState struct {
cachedWidth int
cachedHeight int
viewDirty bool
logViewerVisible bool
logViewerOffset int
logViewerPrevStatus string
logEntries []logEntry
logPersistDirty bool
logPersistVersion int
transcriptContent string
transcriptScrollbarDrag bool
footerErrorLast string
footerErrorText string
footerErrorUntil time.Time
}

type pendingImageAttachment struct {
Expand Down
8 changes: 7 additions & 1 deletion internal/tui/core/app/keymap.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type keyMap struct {
Top key.Binding
Bottom key.Binding
PasteImage key.Binding
LogViewer key.Binding
}

func newKeyMap() keyMap {
Expand Down Expand Up @@ -87,11 +88,15 @@ func newKeyMap() keyMap {
key.WithKeys("ctrl+v"),
key.WithHelp("Ctrl+V", "Paste image"),
),
LogViewer: key.NewBinding(
key.WithKeys("ctrl+l"),
key.WithHelp("Ctrl+L", "Log viewer"),
),
}
}

func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Send, k.Newline, k.CancelAgent, k.ToggleHelp, k.Quit}
return []key.Binding{k.Send, k.Newline, k.CancelAgent, k.LogViewer, k.ToggleHelp, k.Quit}
}

func (k keyMap) FullHelp() [][]key.Binding {
Expand All @@ -100,5 +105,6 @@ func (k keyMap) FullHelp() [][]key.Binding {
{k.FocusInput, k.NextPanel, k.PrevPanel},
{k.ToggleHelp, k.Quit, k.PasteImage, k.ScrollUp},
{k.PageUp, k.PageDown, k.Top, k.Bottom},
{k.LogViewer},
}
}
16 changes: 16 additions & 0 deletions internal/tui/core/app/keymap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,19 @@ func TestFullHelpIncludesPasteImage(t *testing.T) {
}
t.Fatalf("expected full help to include paste image binding")
}

func TestFullHelpIncludesLogViewer(t *testing.T) {
keys := newKeyMap()
help := keys.FullHelp()
foundLogViewer := false
for _, row := range help {
for _, binding := range row {
if binding.Help().Key == keys.LogViewer.Help().Key {
foundLogViewer = true
}
}
}
if !foundLogViewer {
t.Fatalf("expected full help to include log viewer binding")
}
}
Loading
Loading