feat(session): add /name command, history provider for claude code, and improve list summary\n\n- Add /name command to set custom display names for sessions\n- Implement GetSessionHistory for claude code agent (fixes empty /history after /switch)\n- Change scanSessionMeta to use last user message as summary instead of first\n- Show custom session names in /list, /switch, and /status\n- Persist session names in SessionManager with full i18n support

This commit is contained in:
chenhg5 committed 2026-03-05 09:15:44 +08:00
1 parent fcdd11a79b
commit 8917f4a2ef
4 files changed
+230 -16

No files matched your search

+95 -6
View File
@@ -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.
+79 -10
View File
@@ -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 <number> <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 <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
+29
View File
@@ -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 <number>\n Resume a session by its list number\n\n" +
"/name [number] <text>\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úmero>\n Reanudar sesión por su número en la lista\n\n" +
"/name [número] <texto>\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 <text>` — name the current session\n`/name <number> <text>` — 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 <texto>` — nombrar la sesión actual\n`/name <número> <texto>` — 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 切换。",
+27
View File
@@ -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))
}