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:
1 parent
fcdd11a79b
commit
8917f4a2ef
4 files changed
+230
-16
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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 切换。",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user