diff --git a/agent/claudecode/claudecode.go b/agent/claudecode/claudecode.go index bb6d6af..c29d414 100644 --- a/agent/claudecode/claudecode.go +++ b/agent/claudecode/claudecode.go @@ -13,6 +13,7 @@ import ( "sort" "strings" "sync" + "time" "unicode/utf8" "github.com/chenhg5/cc-connect/core" @@ -300,18 +301,106 @@ func scanSessionMeta(path string) (string, int) { } if entry.Type == "user" || entry.Type == "assistant" { count++ - if summary == "" && entry.Type == "user" && entry.Message.Content != "" { - s := entry.Message.Content - if utf8.RuneCountInString(s) > 40 { - s = string([]rune(s)[:40]) + "..." - } - summary = s + if entry.Type == "user" && entry.Message.Content != "" { + summary = entry.Message.Content } } } + if utf8.RuneCountInString(summary) > 40 { + summary = string([]rune(summary)[:40]) + "..." + } return summary, count } +// GetSessionHistory reads the Claude Code JSONL transcript and returns user/assistant messages. +func (a *Agent) GetSessionHistory(_ context.Context, sessionID string, limit int) ([]core.HistoryEntry, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + absWorkDir, _ := filepath.Abs(a.workDir) + projectDir := findProjectDir(homeDir, absWorkDir) + if projectDir == "" { + return nil, fmt.Errorf("claudecode: project dir not found") + } + + path := filepath.Join(projectDir, sessionID+".jsonl") + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("claudecode: open session file: %w", err) + } + defer f.Close() + + var entries []core.HistoryEntry + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 256*1024), 256*1024) + + for scanner.Scan() { + var raw struct { + Type string `json:"type"` + Timestamp string `json:"timestamp"` + Message struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"message"` + } + if json.Unmarshal(scanner.Bytes(), &raw) != nil { + continue + } + if raw.Type != "user" && raw.Type != "assistant" { + continue + } + + ts, _ := time.Parse(time.RFC3339Nano, raw.Timestamp) + text := extractTextContent(raw.Message.Content) + if text == "" { + continue + } + + entries = append(entries, core.HistoryEntry{ + Role: raw.Type, + Content: text, + Timestamp: ts, + }) + } + + if limit > 0 && len(entries) > limit { + entries = entries[len(entries)-limit:] + } + return entries, nil +} + +// extractTextContent extracts readable text from Claude Code message content. +// Content can be a plain string or an array of content blocks. +func extractTextContent(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + + // Try plain string first + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + + // Try array of content blocks + var blocks []struct { + Type string `json:"type"` + Text string `json:"text"` + Thinking string `json:"thinking"` + } + if json.Unmarshal(raw, &blocks) != nil { + return "" + } + + for _, b := range blocks { + if b.Type == "text" && b.Text != "" { + return b.Text + } + } + return "" +} + func (a *Agent) Stop() error { return nil } // SetMode changes the permission mode for future sessions. diff --git a/core/engine.go b/core/engine.go index dc8574f..18d64bf 100644 --- a/core/engine.go +++ b/core/engine.go @@ -856,6 +856,7 @@ var builtinCommands = []struct { {[]string{"new"}, "new"}, {[]string{"list", "sessions"}, "list"}, {[]string{"switch"}, "switch"}, + {[]string{"name", "rename"}, "name"}, {[]string{"current"}, "current"}, {[]string{"status"}, "status"}, {[]string{"history"}, "history"}, @@ -945,6 +946,8 @@ func (e *Engine) handleCommand(p Platform, msg *Message, raw string) bool { e.cmdList(p, msg) case "switch": e.cmdSwitch(p, msg, args) + case "name": + e.cmdName(p, msg, args) case "current": e.cmdCurrent(p, msg) case "status": @@ -1044,16 +1047,21 @@ func (e *Engine) cmdList(p Platform, msg *Message) { if s.ID == activeAgentID { marker = "▶" } - summary := strings.ReplaceAll(s.Summary, "\n", " ") - summary = strings.Join(strings.Fields(summary), " ") - if summary == "" { - summary = "(empty)" - } - if len([]rune(summary)) > 40 { - summary = string([]rune(summary)[:40]) + "…" + displayName := e.sessions.GetSessionName(s.ID) + if displayName != "" { + displayName = "📌 " + displayName + } else { + displayName = strings.ReplaceAll(s.Summary, "\n", " ") + displayName = strings.Join(strings.Fields(displayName), " ") + if displayName == "" { + displayName = "(empty)" + } + if len([]rune(displayName)) > 40 { + displayName = string([]rune(displayName)[:40]) + "…" + } } sb.WriteString(fmt.Sprintf("%s **%d.** %s · **%d** msgs · %s\n", - marker, i+1, summary, s.MessageCount, s.ModifiedAt.Format("01-02 15:04"))) + marker, i+1, displayName, s.MessageCount, s.ModifiedAt.Format("01-02 15:04"))) } if len(agentSessions) > limit { sb.WriteString(fmt.Sprintf(e.i18n.T(MsgListMore), len(agentSessions)-limit)) @@ -1108,8 +1116,65 @@ func (e *Engine) cmdSwitch(p Platform, msg *Message, args []string) { if len(shortID) > 12 { shortID = shortID[:12] } + displayName := e.sessions.GetSessionName(matched.ID) + if displayName == "" { + displayName = matched.Summary + } e.reply(p, msg.ReplyCtx, - fmt.Sprintf("✅ Switched to: %s (%s, %d msgs)", matched.Summary, shortID, matched.MessageCount)) + fmt.Sprintf("✅ Switched to: %s (%s, %d msgs)", displayName, shortID, matched.MessageCount)) +} + +func (e *Engine) cmdName(p Platform, msg *Message, args []string) { + if len(args) == 0 { + e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgNameUsage))) + return + } + + // Check if first arg is a number → naming a specific session by list index + var targetID string + var name string + + if idx, err := strconv.Atoi(args[0]); err == nil && idx >= 1 { + // /name + if len(args) < 2 { + e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgNameUsage))) + return + } + agentSessions, err := e.agent.ListSessions(e.ctx) + if err != nil { + e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ %v", err)) + return + } + if idx > len(agentSessions) { + e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ No session #%d", idx)) + return + } + targetID = agentSessions[idx-1].ID + name = strings.Join(args[1:], " ") + } else { + // /name → current session + session := e.sessions.GetOrCreateActive(msg.SessionKey) + targetID = session.AgentSessionID + if targetID == "" { + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgNameNoSession)) + return + } + name = strings.Join(args, " ") + } + + name = strings.TrimSpace(name) + if name == "" { + e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgNameUsage))) + return + } + + e.sessions.SetSessionName(targetID, name) + + shortID := targetID + if len(shortID) > 12 { + shortID = shortID[:12] + } + e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgNameSet), name, shortID)) } func (e *Engine) cmdCurrent(p Platform, msg *Message) { @@ -1152,7 +1217,11 @@ func (e *Engine) cmdStatus(p Platform, msg *Message) { // Session info s := e.sessions.GetOrCreateActive(msg.SessionKey) - sessionStr := e.i18n.Tf(MsgStatusSession, s.Name, len(s.History)) + sessionDisplayName := e.sessions.GetSessionName(s.AgentSessionID) + if sessionDisplayName == "" { + sessionDisplayName = s.Name + } + sessionStr := e.i18n.Tf(MsgStatusSession, sessionDisplayName, len(s.History)) // Cron jobs var cronStr string diff --git a/core/i18n.go b/core/i18n.go index 6e1ddf5..d46f97d 100644 --- a/core/i18n.go +++ b/core/i18n.go @@ -153,6 +153,9 @@ const ( MsgListSwitchHint MsgKey = "list_switch_hint" MsgListError MsgKey = "list_error" MsgHistoryEmpty MsgKey = "history_empty" + MsgNameUsage MsgKey = "name_usage" + MsgNameSet MsgKey = "name_set" + MsgNameNoSession MsgKey = "name_no_session" MsgProviderNotSupported MsgKey = "provider_not_supported" MsgProviderNone MsgKey = "provider_none" MsgProviderCurrent MsgKey = "provider_current" @@ -455,6 +458,7 @@ var messages = map[MsgKey]map[Language]string{ "/new [name]\n Start a new session\n\n" + "/list\n List agent sessions\n\n" + "/switch \n Resume a session by its list number\n\n" + + "/name [number] \n Name a session for easy identification\n\n" + "/current\n Show current active session\n\n" + "/history [n]\n Show last n messages (default 10)\n\n" + "/provider [list|add|remove|switch|clear]\n Manage API providers\n\n" + @@ -484,6 +488,7 @@ var messages = map[MsgKey]map[Language]string{ "/new [名称]\n 创建新会话\n\n" + "/list\n 列出 Agent 会话列表\n\n" + "/switch <序号>\n 按列表序号切换会话\n\n" + + "/name [序号] <名称>\n 给会话命名,方便识别\n\n" + "/current\n 查看当前活跃会话\n\n" + "/history [n]\n 查看最近 n 条消息(默认 10)\n\n" + "/provider [list|add|remove|switch|clear]\n 管理 API Provider\n\n" + @@ -513,6 +518,7 @@ var messages = map[MsgKey]map[Language]string{ "/new [名稱]\n 建立新會話\n\n" + "/list\n 列出 Agent 會話列表\n\n" + "/switch <序號>\n 按列表序號切換會話\n\n" + + "/name [序號] <名稱>\n 為會話命名,方便辨識\n\n" + "/current\n 查看當前活躍會話\n\n" + "/history [n]\n 查看最近 n 條訊息(預設 10)\n\n" + "/provider [list|add|remove|switch|clear]\n 管理 API Provider\n\n" + @@ -542,6 +548,7 @@ var messages = map[MsgKey]map[Language]string{ "/new [名前]\n 新しいセッションを開始\n\n" + "/list\n エージェントセッション一覧\n\n" + "/switch <番号>\n リスト番号でセッションを切り替え\n\n" + + "/name [番号] <名前>\n セッションに名前を付ける\n\n" + "/current\n 現在のアクティブセッションを表示\n\n" + "/history [n]\n 直近 n 件のメッセージを表示(デフォルト 10)\n\n" + "/provider [list|add|remove|switch|clear]\n API プロバイダ管理\n\n" + @@ -571,6 +578,7 @@ var messages = map[MsgKey]map[Language]string{ "/new [nombre]\n Iniciar una nueva sesión\n\n" + "/list\n Listar sesiones del agente\n\n" + "/switch \n Reanudar sesión por su número en la lista\n\n" + + "/name [número] \n Nombrar una sesión para fácil identificación\n\n" + "/current\n Mostrar sesión activa actual\n\n" + "/history [n]\n Mostrar últimos n mensajes (por defecto 10)\n\n" + "/provider [list|add|remove|switch|clear]\n Gestionar proveedores API\n\n" + @@ -639,6 +647,27 @@ var messages = map[MsgKey]map[Language]string{ LangJapanese: "現在のセッションに履歴がありません。", LangSpanish: "No hay historial en la sesión actual.", }, + MsgNameUsage: { + LangEnglish: "Usage:\n`/name ` — name the current session\n`/name ` — name a session by list number", + LangChinese: "用法:\n`/name <名称>` — 命名当前会话\n`/name <序号> <名称>` — 按列表序号命名会话", + LangTraditionalChinese: "用法:\n`/name <名稱>` — 命名當前會話\n`/name <序號> <名稱>` — 按列表序號命名會話", + LangJapanese: "使い方:\n`/name <名前>` — 現在のセッションに名前を付ける\n`/name <番号> <名前>` — リスト番号でセッションに名前を付ける", + LangSpanish: "Uso:\n`/name ` — nombrar la sesión actual\n`/name ` — nombrar una sesión por número de lista", + }, + MsgNameSet: { + LangEnglish: "✅ Session named: **%s** (%s)", + LangChinese: "✅ 会话已命名:**%s** (%s)", + LangTraditionalChinese: "✅ 會話已命名:**%s** (%s)", + LangJapanese: "✅ セッション名設定:**%s** (%s)", + LangSpanish: "✅ Sesión nombrada: **%s** (%s)", + }, + MsgNameNoSession: { + LangEnglish: "❌ No active session. Send a message first or switch to a session.", + LangChinese: "❌ 没有活跃会话,请先发送消息或切换到一个会话。", + LangTraditionalChinese: "❌ 沒有活躍會話,請先傳送訊息或切換到一個會話。", + LangJapanese: "❌ アクティブなセッションがありません。メッセージを送信するかセッションに切り替えてください。", + LangSpanish: "❌ No hay sesión activa. Envía un mensaje primero o cambia a una sesión.", + }, MsgProviderNotSupported: { LangEnglish: "This agent does not support provider switching.", LangChinese: "当前 Agent 不支持 Provider 切换。", diff --git a/core/session.go b/core/session.go index dce32a9..c3277f6 100644 --- a/core/session.go +++ b/core/session.go @@ -75,6 +75,7 @@ type sessionSnapshot struct { ActiveSession map[string]string `json:"active_session"` UserSessions map[string][]string `json:"user_sessions"` Counter int64 `json:"counter"` + SessionNames map[string]string `json:"session_names,omitempty"` // agent session ID → custom name } // SessionManager supports multiple named sessions per user with active-session tracking. @@ -84,6 +85,7 @@ type SessionManager struct { sessions map[string]*Session activeSession map[string]string userSessions map[string][]string + sessionNames map[string]string // agent session ID → custom name counter int64 storePath string // empty = no persistence } @@ -93,6 +95,7 @@ func NewSessionManager(storePath string) *SessionManager { sessions: make(map[string]*Session), activeSession: make(map[string]string), userSessions: make(map[string][]string), + sessionNames: make(map[string]string), storePath: storePath, } if storePath != "" { @@ -176,6 +179,25 @@ func (sm *SessionManager) ActiveSessionID(userKey string) string { return sm.activeSession[userKey] } +// SetSessionName sets a custom display name for an agent session. +func (sm *SessionManager) SetSessionName(agentSessionID, name string) { + sm.mu.Lock() + defer sm.mu.Unlock() + if name == "" { + delete(sm.sessionNames, agentSessionID) + } else { + sm.sessionNames[agentSessionID] = name + } + sm.saveLocked() +} + +// GetSessionName returns the custom name for an agent session, or "". +func (sm *SessionManager) GetSessionName(agentSessionID string) string { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.sessionNames[agentSessionID] +} + // Save persists current state to disk. Safe to call from outside (e.g. after message processing). func (sm *SessionManager) Save() { sm.mu.RLock() @@ -208,6 +230,7 @@ func (sm *SessionManager) saveLocked() { ActiveSession: sm.activeSession, UserSessions: sm.userSessions, Counter: sm.counter, + SessionNames: sm.sessionNames, } data, err := json.MarshalIndent(snap, "", " ") if err != nil { @@ -239,6 +262,7 @@ func (sm *SessionManager) load() { sm.sessions = snap.Sessions sm.activeSession = snap.ActiveSession sm.userSessions = snap.UserSessions + sm.sessionNames = snap.SessionNames sm.counter = snap.Counter if sm.sessions == nil { @@ -250,6 +274,9 @@ func (sm *SessionManager) load() { if sm.userSessions == nil { sm.userSessions = make(map[string][]string) } + if sm.sessionNames == nil { + sm.sessionNames = make(map[string]string) + } slog.Info("session: loaded from disk", "path", sm.storePath, "sessions", len(sm.sessions)) }