Files
wehub-resource-sync f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

167 lines
4.8 KiB
Go

// ABOUTME: human-mode rendering helpers for `session list` — the
// ABOUTME: resume-oriented table (in-flight marker, ID, AGE, AGENT,
// ABOUTME: PROJECT, BRANCH, MSGS, NAME, ~-collapsed CWD) and the field
// ABOUTME: formatters it relies on.
package main
import (
"strconv"
"strings"
"time"
"unicode/utf8"
"go.kenn.io/agentsview/internal/db"
)
// resumeActiveWindow flags a session as in-flight when its last activity
// is within this window of now. It mirrors agentsview-mcp's activeWindow:
// recent activity means the session is resumable right now, so it gets the
// in-flight marker and `--resume`/`--active` surface it.
const resumeActiveWindow = 15 * time.Minute
// activeMarker is the in-flight dot rendered for recently-active sessions.
const activeMarker = "●"
// sessionActivityTime returns a session's last-activity time: EndedAt if
// present and parseable, else StartedAt, else CreatedAt, else the zero time.
// Timestamps are stored as RFC3339/RFC3339Nano strings, so both layouts are
// accepted. CreatedAt is the final fallback so AGE and the in-flight marker
// agree with the backend active_since filter and recent sort, which fall back
// to COALESCE(ended_at, started_at, created_at).
func sessionActivityTime(s db.Session) time.Time {
for _, ts := range []*string{s.EndedAt, s.StartedAt} {
if ts == nil {
continue
}
if t, ok := parseSessionTime(*ts); ok {
return t
}
}
if t, ok := parseSessionTime(s.CreatedAt); ok {
return t
}
return time.Time{}
}
// parseSessionTime parses an RFC3339 or RFC3339Nano timestamp.
func parseSessionTime(s string) (time.Time, bool) {
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, true
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t, true
}
return time.Time{}, false
}
// isSessionRecentlyActive reports whether the session's last activity is
// within resumeActiveWindow of now. A timestamp slightly in the future
// (clock skew, or a live session whose last activity is momentarily ahead
// of our clock) counts as active, matching the "now" label humanizeAge
// gives it.
func isSessionRecentlyActive(s db.Session, now time.Time) bool {
t := sessionActivityTime(s)
if t.IsZero() {
return false
}
return now.Sub(t) < resumeActiveWindow
}
// humanizeSessionAge renders a session's last-activity time relative to
// now: seconds/minutes/hours/days for the recent past, an absolute month
// and day beyond a week, and an em dash when no timestamp is available.
func humanizeSessionAge(s db.Session, now time.Time) string {
t := sessionActivityTime(s)
if t.IsZero() {
return emDash
}
d := now.Sub(t)
switch {
case d < 0:
return "now"
case d < time.Minute:
return strconv.Itoa(int(d.Seconds())) + "s"
case d < time.Hour:
return strconv.Itoa(int(d.Minutes())) + "m"
case d < 24*time.Hour:
return strconv.Itoa(int(d.Hours())) + "h"
case d < 7*24*time.Hour:
return strconv.Itoa(int(d/(24*time.Hour))) + "d"
default:
return t.Format("Jan 02")
}
}
// sessionDisplayName is the session's human label: DisplayName when set,
// otherwise the first message.
func sessionDisplayName(s db.Session) string {
if s.DisplayName != nil && *s.DisplayName != "" {
return *s.DisplayName
}
if s.FirstMessage != nil {
return *s.FirstMessage
}
return ""
}
// emDash stands in for empty optional fields (codex sessions have no cwd
// or branch) so a column reads as intentionally-absent, not blank.
const emDash = "—"
// orEmDash returns s, or an em dash when s is blank.
func orEmDash(s string) string {
if strings.TrimSpace(s) == "" {
return emDash
}
return s
}
// collapseHome rewrites a home-prefixed cwd to ~ form for a compact,
// relaunch-friendly path. A blank cwd becomes an em dash.
func collapseHome(cwd, home string) string {
if strings.TrimSpace(cwd) == "" {
return emDash
}
if home != "" {
if cwd == home {
return "~"
}
if strings.HasPrefix(cwd, home+"/") {
return "~" + cwd[len(home):]
}
}
return cwd
}
// truncName collapses internal whitespace and trims to max runes, adding
// an ellipsis when it cut anything.
func truncName(s string, max int) string {
t, cut := truncateRunes(collapseWhitespace(s), max)
if cut {
return t + "…"
}
return t
}
// collapseWhitespace folds runs of whitespace (including newlines) into a
// single space so a multi-line first message stays on one table row.
func collapseWhitespace(s string) string {
return strings.Join(strings.Fields(s), " ")
}
// truncateRunes cuts s to at most max runes on a rune boundary, returning
// the (possibly shortened) string and whether truncation occurred.
func truncateRunes(s string, max int) (string, bool) {
if max <= 0 || utf8.RuneCountInString(s) <= max {
return s, false
}
n := 0
for i := range s {
if n == max {
return s[:i], true
}
n++
}
return s, false
}