From cbd3b1d8a9228a153c54636b37a1e7ffa8509c23 Mon Sep 17 00:00:00 2001 From: smoido Date: Mon, 14 Sep 2026 23:40:24 +0300 Subject: [PATCH] Dismiss broken sessions, and hide ones untouched for a week A broken session that will never be repaired kept the bar's warning dot lit forever. repair-session now takes --ignore/--unignore/--unignore-all/--ignored, recorded in ~/.claude-mode/ignored-sessions.json, and the scan hides broken transcripts older than --max-age days (default 7, 0 disables, CM_IGNORE_AGE_DAYS sets it). Hidden sessions move to a separate ignored[] list with a reason, and --all always names how many it held back. A repair clears the session's dismissal, and entries whose transcript is gone are pruned, so a session that breaks again is never silently hidden. The panel gets an Ignore button beside Repair and a collapsed "hidden (N)" section with Restore. The dot still counts broken sessions only. Bumps to 1.10.0 and fixes the widget manifest, which still said 1.8.0. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 21 ++ VERSION | 2 +- linux/claude-mode | 125 +++++++++++- linux/cm-json.py | 242 ++++++++++++++++++----- omarchy/smoido.claude-mode/BarWidget.qml | 21 +- omarchy/smoido.claude-mode/Panel.qml | 199 ++++++++++++++++++- omarchy/smoido.claude-mode/manifest.json | 2 +- 7 files changed, 543 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 7b2a969..9193913 100644 --- a/README.md +++ b/README.md @@ -430,6 +430,27 @@ session id is looked up across every project** — you rarely remember which project a session you cannot resume belonged to. `--all` drops the scoping entirely. +Not every broken session is worth repairing — a throwaway, or one whose work +was finished some other way — and one that can never be cleared keeps the bar's +warning dot lit for good. So a session can be dismissed, and one untouched for +more than 7 days is hidden on its own: + +```bash +claude-mode repair-session --ignore # stop counting it +claude-mode repair-session --ignored # what is dismissed, and whether it is still broken +claude-mode repair-session --unignore # or --unignore-all +claude-mode repair-session --all --max-age 0 # include the age-hidden ones (CM_IGNORE_AGE_DAYS sets the default) +``` + +Neither touches the transcript. Hidden sessions are always named — `--all` +ends with a line like `2 hidden: 1 ignored, 1 older than 7 days` — so age-hiding +never looks like damage disappearing. Dismissals live in +`~/.claude-mode/ignored-sessions.json`; an entry is dropped when its transcript +is deleted, and when the session is repaired, so a session that breaks again +later is not silently hidden. In the bar panel each broken session has an +**Ignore** button beside **Repair**, and the hidden ones sit under a collapsed +`hidden (N)` row with **Restore**. + `--all` reports only what is actually actionable, which matters more than it sounds. Of 59 transcripts here it initially flagged 16; on inspection 5 had simply never received a reply, and 10 had run start-to-finish on a gateway, so diff --git a/VERSION b/VERSION index 77fee73..81c871d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.9.3 +1.10.0 diff --git a/linux/claude-mode b/linux/claude-mode index 8b1478f..aa03fb6 100755 --- a/linux/claude-mode +++ b/linux/claude-mode @@ -16,6 +16,7 @@ CM_BIN="$CM_ROOT/bin" CM_PRESETS="$CM_ROOT/presets" CM_BACKUPS="$CM_ROOT/backups" CM_STATE="$CM_ROOT/state.json" +CM_IGNORED="$CM_ROOT/ignored-sessions.json" CM_SETTINGS_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" CM_SETTINGS="$CM_SETTINGS_DIR/settings.json" CM_HELPER="$CM_BIN/claude-key-helper.sh" @@ -281,6 +282,11 @@ claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio claude-mode repair-session [id] [--apply] make a session resumable again after a bad switch, keeping the cut turns as markdown + a context note + claude-mode repair-session --ignore | --unignore | --unignore-all | --ignored + stop (or resume) counting one broken session + claude-mode repair-session --all [--max-age ] + broken sessions untouched for longer are hidden + (default 7; 0 shows them all) claude-mode --force switch even if preflight says no claude-mode --yes switch without asking about running sessions EOF @@ -2256,9 +2262,59 @@ cm_file_mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0 } +# Dismissing is bookkeeping, not repair: the transcript is left exactly as it +# is, and only whether the scan - and so the bar's warning dot - counts it +# changes. Hence no confirmation, and one flag to undo it. +cm_ignore_session() { + local act="$1" id="${2:-}" out op pruned projects="$CM_SETTINGS_DIR/projects" + case "$act" in + list) + out="$("$PY" "$JSON" ignore-session "$CM_IGNORED" "$projects" list 2>&1)" || { + err "$out"; return 1; } + head_ 'dismissed sessions' + printf '%s' "$out" | "$PY" -c " +import json,sys +d=json.load(sys.stdin) +D,X='\033[90m','\033[0m' +if not d['sessions']: + print(' none') +for s in d['sessions']: + state = 'still broken' if s['broken'] else ('transcript gone' if not s['exists'] else 'no longer broken') + print(' %-38s %s' % (s['sessionId'], s['project'])) + print(' %signored %s, %s%s' % (D, s['ignoredAt'][:10], state, X)) +print() +print(' %sclaude-mode repair-session --unignore (or --unignore-all)%s' % (D,X)) +" + return 0 ;; + ignore|unignore) + [ -n "$id" ] || { err "--$act needs a session id"; return 1; } + [ "$act" = ignore ] && op=add || op=remove ;; + unignore-all) + op=clear ;; + esac + + out="$("$PY" "$JSON" ignore-session "$CM_IGNORED" "$projects" "$op" "${id%.jsonl}" 2>&1)" || { + err "$out"; return 1; } + id="${id%.jsonl}" + case "$act:$out" in + ignore:*'"changed": true'*) ok "hidden $id" + say "${C_DIM}claude-mode repair-session --unignore $id brings it back${C_RESET}" ;; + ignore:*) ok "$id was already hidden" ;; + unignore:*'"changed": true'*) ok "restored $id" ;; + unignore:*) ok "$id was not hidden" ;; + *'"changed": true'*) ok 'restored every dismissed session' ;; + *) ok 'nothing was dismissed' ;; + esac + pruned="$(printf '%s' "$out" | sed -n 's/.*"pruned": \([0-9]*\).*/\1/p')" + [ "${pruned:-0}" -gt 0 ] && say "${C_DIM}(forgot $pruned whose transcript no longer exists)${C_RESET}" + return 0 +} + cmd_repair_session() { local target='' apply=0 reinject=1 scan_all=0 as_json=0 dir='' a file verdict age - for a in "$@"; do + local ignore_act='' max_age="${CM_IGNORE_AGE_DAYS:-}" + while [ $# -gt 0 ]; do + a="$1"; shift case "$a" in --apply) apply=1 ;; --dry-run) apply=0 ;; @@ -2266,18 +2322,37 @@ cmd_repair_session() { --list) target='--list' ;; --all) scan_all=1 ;; --json) as_json=1; scan_all=1 ;; + # The id may follow the flag or stand anywhere as the positional, so + # `--ignore ` and ` --ignore` both do what they say. + --ignore|--unignore) + ignore_act="${a#--}" + if [ $# -gt 0 ] && [ "${1#-}" = "$1" ]; then target="$1"; shift; fi ;; + --unignore-all) ignore_act='unignore-all' ;; + --ignored) ignore_act='list' ;; + --max-age) + [ $# -gt 0 ] || { err '--max-age needs a number of days'; return 1; } + max_age="$1"; shift ;; -*) err "unknown option '$a'"; return 1 ;; *) target="$a" ;; esac done + if [ -n "$max_age" ] && ! [[ "$max_age" =~ ^[0-9]+$ ]]; then + err "max age is a whole number of days, not '$max_age' (--max-age / CM_IGNORE_AGE_DAYS)" + return 1 + fi + + if [ -n "$ignore_act" ]; then + cm_ignore_session "$ignore_act" "$target" + return $? + fi # A session you need to repair is one you could not resume, which is a poor # position from which to remember which project it belonged to. --all drops # the working-directory scoping and reports only what is actually broken. if [ "$scan_all" -eq 1 ]; then local scan - scan="$("$PY" "$JSON" scan-sessions "$CM_SETTINGS_DIR/projects" 2>/dev/null)" || { - err 'could not scan transcripts'; return 1; } + scan="$("$PY" "$JSON" scan-sessions "$CM_SETTINGS_DIR/projects" "$max_age" "$CM_IGNORED" 2>&1)" || { + err "could not scan transcripts: $scan"; return 1; } if [ "$as_json" -eq 1 ]; then printf '%s\n' "$scan" @@ -2294,11 +2369,24 @@ for b in d['broken']: prov = ', '.join(b['providers']) or 'another provider' print(' %d line(s) after the last good message, from %s' % (b['dropLines'], prov)) print() +ig = d.get('ignored') or [] if not d['broken']: - print(' %sok %s nothing to repair across %d transcript(s)' % (G,X,d['scanned'])) + print(' %sok %s nothing to repair across %d transcript(s)%s' % (G,X,d['scanned'], + ' that is not hidden' if ig else '')) else: print(' %swarn%s %d of %d transcript(s) were cut short by a mode switch' % (Y,X,d['count'],d['scanned'])) - print(' %sclaude-mode repair-session --apply%s' % (D,X)) + print(' %sclaude-mode repair-session --apply (or --ignore to stop counting it)%s' % (D,X)) +# Age-hiding must never look like damage vanishing, so whatever was held back +# is always named, with the way to see it. +if ig: + nd = sum(1 for i in ig if i.get('reason') == 'dismissed') + ns = len(ig) - nd + bits, hints = [], [] + if nd: + bits.append('%d ignored' % nd); hints.append('--ignored lists them') + if ns: + bits.append('%d older than %d days' % (ns, d.get('maxAgeDays', 0))); hints.append('--max-age 0 shows all') + print(' %s%d hidden: %s (%s)%s' % (D, len(ig), ', '.join(bits), '; '.join(hints), X)) print(' %ssessions that ran entirely on a gateway are not listed: they carry that%s' % (D,X)) print(' %sprovider\'s ids by design and resume fine under it%s' % (D,X)) " @@ -2332,15 +2420,30 @@ print(' %sprovider\'s ids by design and resume fine under it%s' % (D,X)) if [ -z "$target" ] || [ "$target" = "--list" ]; then head_ 'session transcripts here' - local f v + # This listing classifies each file itself rather than going through + # the scan, so it asks the scan which ones are hidden. It still shows + # them - listing everything here is its job - but says why the bar + # is not counting them. + local f v hidden reason + hidden="$("$PY" "$JSON" scan-sessions "$CM_SETTINGS_DIR/projects" "$max_age" "$CM_IGNORED" 2>/dev/null \ + | "$PY" -c " +import json,sys +d=json.load(sys.stdin) +for i in d.get('ignored') or []: + print('%s\t%s' % (i['sessionId'], 'ignored' if i.get('reason') == 'dismissed' + else 'older than %d days' % d.get('maxAgeDays', 0))) +" 2>/dev/null)" for f in $(ls -1t "$dir"/*.jsonl 2>/dev/null); do v="$("$PY" "$JSON" repair-session "$f" 2>/dev/null)" || continue + reason="$(printf '%s\n' "$hidden" | awk -F'\t' -v s="$(basename "$f" .jsonl)" '$1 == s { print $2; exit }')" printf '%s' "$v" | "$PY" -c " import json,sys,os d=json.load(sys.stdin) state = 'ok' if d['healthy'] else ('repairable, would drop %d line(s)' % d['dropLines'] if d['repairable'] else 'no anthropic message found') +if sys.argv[1]: + state += ' (hidden: %s)' % sys.argv[1] print(' %-40s %s' % (os.path.basename(d['path'])[:-6], state)) -" +" "$reason" done printf '\n %sclaude-mode repair-session --apply%s\n' "$C_DIM" "$C_RESET" printf ' %s--all checks every project, not just this one%s\n' "$C_DIM" "$C_RESET" @@ -2366,6 +2469,14 @@ print(' %-40s %s' % (os.path.basename(d['path'])[:-6], state)) verdict="$("$PY" "$JSON" repair-session "$file" $flags)" || { err 'could not read that transcript'; return 1; } + # A repaired session is no longer damage. Left dismissed, it would stay + # hidden if the same session broke again later. + case "$verdict" in + *'"applied": true'*) + "$PY" "$JSON" ignore-session "$CM_IGNORED" "$CM_SETTINGS_DIR/projects" \ + remove "${target%.jsonl}" >/dev/null 2>&1 ;; + esac + printf '%s' "$verdict" | "$PY" -c " import json,sys d=json.load(sys.stdin) diff --git a/linux/cm-json.py b/linux/cm-json.py index b4f4163..b5e9213 100644 --- a/linux/cm-json.py +++ b/linux/cm-json.py @@ -14,6 +14,8 @@ Subcommands: scaffold print a blank preset get print one value presets "nameproviderdesc" + scan-sessions [max-age-days] [ignored] broken transcripts, as JSON + ignore-session add|remove|clear|list [id] """ import glob @@ -878,15 +880,181 @@ def _last_msg_id_from_tail(path, window=262144): return None, whole -def cmd_scan_sessions(argv): - """scan-sessions - JSON list of transcripts worth repairing. +def _broken_entry(path): + """The scan's record for one transcript if it needs repair, else None. - Only `repairable` is reported: a transcript carrying a genuine Anthropic - message with a different provider's output after it. A session that ran - entirely on a gateway, or never got a reply, is not damage. + Raises OSError when the file cannot be read, which the scan counts as + skipped rather than as healthy. + """ + last, whole = _last_msg_id_from_tail(path) + # Healthy is decided by the tail alone, and is the common case. + if last is not None and last.startswith("msg_"): + return None + if last is None and whole: + return None # no replies at all; nothing to fix + + with open(path, encoding="utf-8", errors="replace") as fh: + lines = fh.read().splitlines() + + last_good, foreign = -1, [] + for i, line in enumerate(lines): + if not line.strip(): + continue + try: + d = json.loads(line) + except Exception: + continue + mid = _msg_id(d) + if not mid: + continue + if mid.startswith("msg_"): + last_good = i + elif mid.startswith("gen-"): + foreign.append(str((d.get("message") or {}).get("model", ""))) + + if last_good < 0: + return None # gateway-native or reply-less + tail = [i for i in range(len(lines) - 1, last_good, -1) + if lines[i].strip() and _msg_id(_safe(lines[i])) is not None] + if not tail: + return None + + return { + "sessionId": os.path.basename(path)[:-6], + "project": os.path.basename(os.path.dirname(path)), + "path": path, + "lines": len(lines), + "dropLines": len(lines) - (last_good + 1), + "providers": sorted(set(f for f in foreign if f)), + "mtime": int(os.path.getmtime(path)), + } + + +# --------------------------------------------------------------------------- +# Dismissed sessions +# +# Some damage is never going to be repaired - a throwaway session, or one whose +# work was finished another way - and without a way to say so it holds the +# bar's warning dot on forever, which teaches you to ignore the dot. So a broken +# session can be dismissed, and one untouched for longer than a threshold is +# hidden on its own. Neither is deleted or forgotten: both move to `ignored[]`, +# with the reason, and stay one command away. +# +# Its own file because nothing else can hold authored state: state.json is +# rewritten wholesale on every switch, health.json on every `health`. +# --------------------------------------------------------------------------- + +IGNORE_AGE_DAYS = 7 +SESSION_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$") + + +def _load_ignored(path): + try: + data = load(path, {}) + except (OSError, ValueError): + data = {} # a corrupt file must not break the scan + sessions = data.get("sessions") if isinstance(data, dict) else None + return sessions if isinstance(sessions, dict) else {} + + +def _save_ignored(path, sessions): + save(path, {"schema": 1, "sessions": sessions}) + + +def _find_transcript(projects, sid): + hits = sorted(glob.glob(os.path.join(projects, "*", sid + ".jsonl"))) + return hits[0] if hits else None + + +def _prune_ignored(projects, sessions): + """Drop entries whose transcript has gone, so a session id that is later + reused - or restored from a backup - does not come back already hidden.""" + gone = [sid for sid in sessions if not _find_transcript(projects, sid)] + for sid in gone: + del sessions[sid] + return len(gone) + + +def cmd_ignore_session(argv): + """ignore-session add|remove|clear|list [id] + + Prints JSON. `list` reconciles every entry against the disk: whether the + transcript still exists, and whether it is still broken at all. + """ + path, projects, action = argv[0], argv[1], argv[2] + sid = argv[3] if len(argv) > 3 else "" + if sid.endswith(".jsonl"): + sid = sid[:-6] + if action in ("add", "remove") and not SESSION_ID_RE.match(sid): + raise SystemExit("not a session id: '%s'" % sid) + + sessions = _load_ignored(path) + + if action == "list": + rows = [] + for key in sorted(sessions, key=lambda k: str(sessions[k].get("ignoredAt", ""))): + found = _find_transcript(projects, key) + try: + still = bool(found and _broken_entry(found)) + except OSError: + still = False + rows.append({"sessionId": key, + "project": sessions[key].get("project", ""), + "ignoredAt": sessions[key].get("ignoredAt", ""), + "exists": bool(found), "broken": still}) + print(json.dumps({"sessions": rows, "count": len(rows)})) + return + + pruned = _prune_ignored(projects, sessions) + changed = False + + if action == "add": + found = _find_transcript(projects, sid) + if not found: + raise SystemExit("no transcript for session '%s'" % sid) + if sid not in sessions: + sessions[sid] = { + "project": os.path.basename(os.path.dirname(found)), + "ignoredAt": __import__("datetime").datetime.now( + __import__("datetime").timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + changed = True + elif action == "remove": + changed = sessions.pop(sid, None) is not None + elif action == "clear": + changed = bool(sessions) + sessions = {} + else: + raise SystemExit("ignore-session: unknown action '%s'" % action) + + if changed or pruned: + _save_ignored(path, sessions) + print(json.dumps({"action": action, "sessionId": sid, "changed": changed, + "pruned": pruned, "count": len(sessions)})) + + +def cmd_scan_sessions(argv): + """scan-sessions [max-age-days] [ignored.json] + + JSON list of transcripts worth repairing. Only `repairable` is reported: a + transcript carrying a genuine Anthropic message with a different provider's + output after it. A session that ran entirely on a gateway, or never got a + reply, is not damage. + + Broken transcripts that were dismissed, or not written to for longer than + max-age-days (default 7; 0 turns the age rule off), are reported under + `ignored[]` with a reason instead of under `broken[]`. """ root = argv[0] - broken, scanned, skipped = [], 0, 0 + age_arg = argv[1] if len(argv) > 1 else "" + try: + max_age = int(age_arg) if age_arg != "" else IGNORE_AGE_DAYS + except ValueError: + raise SystemExit("max age must be a whole number of days, not '%s'" % age_arg) + dismissed = _load_ignored(argv[2]) if len(argv) > 2 and argv[2] else {} + cutoff = __import__("time").time() - max_age * 86400 if max_age > 0 else None + + broken, ignored, scanned, skipped = [], [], 0, 0 for proj in sorted(glob.glob(os.path.join(root, "*"))): if not os.path.isdir(proj): @@ -894,58 +1062,27 @@ def cmd_scan_sessions(argv): for path in sorted(glob.glob(os.path.join(proj, "*.jsonl"))): scanned += 1 try: - last, whole = _last_msg_id_from_tail(path) + entry = _broken_entry(path) except OSError: skipped += 1 continue - # Healthy is decided by the tail alone, and is the common case. - if last is not None and last.startswith("msg_"): + if entry is None: continue - if last is None and whole: - continue # no replies at all; nothing to fix - - try: - with open(path, encoding="utf-8", errors="replace") as fh: - lines = fh.read().splitlines() - except OSError: - skipped += 1 - continue - - last_good, foreign = -1, [] - for i, line in enumerate(lines): - if not line.strip(): - continue - try: - d = json.loads(line) - except Exception: - continue - mid = _msg_id(d) - if not mid: - continue - if mid.startswith("msg_"): - last_good = i - elif mid.startswith("gen-"): - foreign.append(str((d.get("message") or {}).get("model", ""))) - - if last_good < 0: - continue # gateway-native or reply-less - tail = [i for i in range(len(lines) - 1, last_good, -1) - if lines[i].strip() and _msg_id(_safe(lines[i])) is not None] - if not tail: - continue - - broken.append({ - "sessionId": os.path.basename(path)[:-6], - "project": os.path.basename(proj), - "path": path, - "lines": len(lines), - "dropLines": len(lines) - (last_good + 1), - "providers": sorted(set(f for f in foreign if f)), - "mtime": int(os.path.getmtime(path)), - }) + # Dismissed outranks stale: it is the reason a person gave. + if entry["sessionId"] in dismissed: + entry["reason"] = "dismissed" + entry["ignoredAt"] = dismissed[entry["sessionId"]].get("ignoredAt", "") + ignored.append(entry) + elif cutoff is not None and entry["mtime"] < cutoff: + entry["reason"] = "stale" + ignored.append(entry) + else: + broken.append(entry) print(json.dumps({"scanned": scanned, "skipped": skipped, - "broken": broken, "count": len(broken)})) + "broken": broken, "count": len(broken), + "ignored": ignored, "ignoredCount": len(ignored), + "maxAgeDays": max_age})) COMMANDS = { "health": cmd_health, @@ -953,6 +1090,7 @@ COMMANDS = { "sessions-json": cmd_sessions_json, "repair-session": cmd_repair_session, "scan-sessions": cmd_scan_sessions, + "ignore-session": cmd_ignore_session, "stale-models": cmd_stale_models, "strip-tags": cmd_strip_tags, "or-models": cmd_or_models, diff --git a/omarchy/smoido.claude-mode/BarWidget.qml b/omarchy/smoido.claude-mode/BarWidget.qml index ab556e5..2580e90 100644 --- a/omarchy/smoido.claude-mode/BarWidget.qml +++ b/omarchy/smoido.claude-mode/BarWidget.qml @@ -132,6 +132,12 @@ BarWidget { // to sit on a timer: ~60ms for 59 transcripts here, against 5s for the naive // version that read every byte of every one. property var brokenSessions: [] + // Dismissed, or broken but untouched for longer than the age limit. Kept + // out of the dot on purpose: hiding one is how you tell the bar to stop + // asking about it. + property var ignoredSessions: [] + property int ignoreAgeDays: 7 + property bool rescanPending: false Process { id: scanProc @@ -143,11 +149,24 @@ BarWidget { var d = null try { d = JSON.parse(String(text || "")) } catch (e) { d = null } root.brokenSessions = (d && d.broken) ? d.broken : [] + root.ignoredSessions = (d && d.ignored) ? d.ignored : [] + if (d && d.maxAgeDays !== undefined) root.ignoreAgeDays = Number(d.maxAgeDays) + } + } + // A scan already under way was started before whatever asked for this + // one, so its answer may predate the change - run once more after it. + onExited: function (code) { + if (root.rescanPending) { + root.rescanPending = false + Qt.callLater(root.scanSessions) } } } - function scanSessions() { if (!scanProc.running) scanProc.running = true } + function scanSessions() { + if (scanProc.running) root.rescanPending = true + else scanProc.running = true + } Timer { interval: 20 * 60 * 1000 diff --git a/omarchy/smoido.claude-mode/Panel.qml b/omarchy/smoido.claude-mode/Panel.qml index 68200c4..45cb70e 100644 --- a/omarchy/smoido.claude-mode/Panel.qml +++ b/omarchy/smoido.claude-mode/Panel.qml @@ -32,6 +32,7 @@ Panel { function open() { root.expandedMode = "" root.lastError = "" + root.hiddenExpanded = false root.resetFlow() if (widget) widget.refresh() root.controller.show() @@ -183,6 +184,47 @@ Panel { } } + // ---- Hidden transcripts + // + // Ignoring only changes whether the scan counts a session - the transcript is + // not touched and Restore is one click away - so unlike Repair it needs no + // confirmation. Age-hidden ones are never called ignored: nobody chose that. + readonly property var ignored: widget && widget.ignoredSessions ? widget.ignoredSessions : [] + readonly property var dismissedList: ignored.filter(function (e) { return String(e.reason) === "dismissed" }) + readonly property var staleList: ignored.filter(function (e) { return String(e.reason) !== "dismissed" }) + readonly property int ageDays: widget ? widget.ignoreAgeDays : 7 + property bool hiddenExpanded: false + // The card clamps rather than scrolls, so a long list would be cut off at + // the bottom edge instead of becoming reachable. The CLI has the full list. + readonly property int hiddenCap: 4 + + function projectLabel(p) { return String(p).replace(/^-/, "").replace(/-/g, "/") } + + function setIgnored(entry, on) { + if (root.busy || !entry) return + root.busy = true + root.lastError = "" + ignoreProc.command = root.cli(["repair-session", on ? "--ignore" : "--unignore", String(entry.sessionId)]) + ignoreProc.running = true + } + + Process { + id: ignoreProc + running: false + stderr: StdioCollector { + waitForEnd: true + onStreamFinished: { + var msg = String(text || "").replace(/\x1b\[[0-9;]*m/g, "").trim() + var lines = msg.split("\n").filter(function (l) { return l.trim() !== "" }) + if (lines.length > 0) root.lastError = lines[lines.length - 1].replace(/^\s*(FAIL|warn)\s*/, "").trim() + } + } + onExited: function (code) { + root.busy = false + if (widget) widget.scanSessions() + } + } + function storeServerKey() { remedyProc.command = root.cli(["set-key", root.serverKeyRef, "--terminal"]) remedyProc.running = true @@ -1014,13 +1056,14 @@ Panel { // point is to be noticed without going looking. Column { width: parent.width - visible: root.stage === "list" && root.broken.length > 0 + visible: root.stage === "list" && (root.broken.length > 0 || root.ignored.length > 0) spacing: Style.space(4) PanelSeparator { width: parent.width } Text { width: parent.width + visible: root.broken.length > 0 text: root.broken.length === 1 ? "1 session cannot be resumed" : root.broken.length + " sessions cannot be resumed" @@ -1035,21 +1078,22 @@ Panel { model: root.stage === "list" ? root.broken : [] Item { + id: brokenRow required property var modelData width: column.width height: Style.space(26) Column { anchors.left: parent.left - anchors.right: repairBtn.left + anchors.right: ignoreBtn.left anchors.rightMargin: Style.space(6) anchors.verticalCenter: parent.verticalCenter spacing: 0 Text { width: parent.width - text: String(parent.parent.modelData.sessionId).substring(0, 8) - + " · " + String(parent.parent.modelData.project).replace(/^-/, "").replace(/-/g, "/") + text: String(brokenRow.modelData.sessionId).substring(0, 8) + + " · " + root.projectLabel(brokenRow.modelData.project) color: Color.popups.text elide: Text.ElideMiddle font.family: root.bar ? root.bar.fontFamily : Style.font.family @@ -1058,7 +1102,7 @@ Panel { Text { width: parent.width - text: parent.parent.modelData.dropLines + " turn lines after the last good message" + text: brokenRow.modelData.dropLines + " turn lines after the last good message" color: Color.muted elide: Text.ElideRight font.family: root.bar ? root.bar.fontFamily : Style.font.family @@ -1066,15 +1110,156 @@ Panel { } } + PillButton { + id: ignoreBtn + anchors.right: repairBtn.left + anchors.rightMargin: Style.space(5) + anchors.verticalCenter: parent.verticalCenter + label: "Ignore" + onTriggered: root.setIgnored(brokenRow.modelData, true) + } + PillButton { id: repairBtn anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter label: "Repair" - onTriggered: root.askRepair(parent.modelData) + onTriggered: root.askRepair(brokenRow.modelData) } } } + + // ---- Hidden: dismissed here, or broken but untouched for longer than + // the age limit. Collapsed, because the point of hiding them was that + // they stop taking up attention - but never gone without a trace. + Item { + width: parent.width + height: Style.space(20) + visible: root.ignored.length > 0 + + Text { + id: hiddenLabel + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: "hidden (" + root.ignored.length + ")" + color: hiddenArea.containsMouse ? Color.popups.text : Color.muted + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(10) + } + + Text { + anchors.left: hiddenLabel.right + anchors.leftMargin: Style.space(4) + anchors.verticalCenter: parent.verticalCenter + text: String.fromCodePoint(root.hiddenExpanded ? 0xF0140 : 0xF0142) + color: hiddenLabel.color + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(11) + } + + MouseArea { + id: hiddenArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.hiddenExpanded = !root.hiddenExpanded + } + } + + Column { + width: parent.width + visible: root.hiddenExpanded && root.ignored.length > 0 + spacing: Style.space(3) + + Text { + visible: root.dismissedList.length > 0 + text: "Ignored (" + root.dismissedList.length + ")" + color: Color.popups.text + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(10) + font.bold: true + } + + Repeater { + model: root.hiddenExpanded ? root.dismissedList.slice(0, root.hiddenCap) : [] + + Item { + id: dismissedRow + required property var modelData + width: column.width + height: Style.space(26) + + Text { + anchors.left: parent.left + anchors.right: restoreBtn.left + anchors.rightMargin: Style.space(6) + anchors.verticalCenter: parent.verticalCenter + text: String(dismissedRow.modelData.sessionId).substring(0, 8) + + " · " + root.projectLabel(dismissedRow.modelData.project) + color: Color.muted + elide: Text.ElideMiddle + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(10) + } + + PillButton { + id: restoreBtn + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + label: "Restore" + onTriggered: root.setIgnored(dismissedRow.modelData, false) + } + } + } + + Text { + visible: root.dismissedList.length > root.hiddenCap + text: "… and " + (root.dismissedList.length - root.hiddenCap) + + " more · claude-mode repair-session --ignored" + color: Color.muted + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(9) + } + + Text { + visible: root.staleList.length > 0 + text: "Older than " + root.ageDays + " days (" + root.staleList.length + ")" + color: Color.popups.text + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(10) + font.bold: true + } + + Repeater { + model: root.hiddenExpanded ? root.staleList.slice(0, root.hiddenCap) : [] + + Text { + required property var modelData + width: column.width + text: String(modelData.sessionId).substring(0, 8) + + " · " + Math.floor((Date.now() / 1000 - Number(modelData.mtime)) / 86400) + "d" + + " · " + root.projectLabel(modelData.project) + color: Color.muted + elide: Text.ElideMiddle + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(10) + } + } + + Text { + width: parent.width + visible: root.staleList.length > 0 + text: (root.staleList.length > root.hiddenCap + ? "… and " + (root.staleList.length - root.hiddenCap) + " more. " : "") + + "Not counted because nothing has touched them since. " + + "claude-mode repair-session --all --max-age 0 lists them." + color: Color.muted + opacity: 0.85 + wrapMode: Text.WordWrap + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(9) + } + } } // ---- Confirming one repair. @@ -1193,7 +1378,7 @@ Panel { Text { width: parent.width text: root.busy - ? "switching…" + ? (ignoreProc.running ? "updating…" : (repairProc.running ? "repairing…" : "switching…")) : (root.known && root.stage === "list" ? "Restart claude to pick this up." : "") visible: text !== "" color: Color.muted diff --git a/omarchy/smoido.claude-mode/manifest.json b/omarchy/smoido.claude-mode/manifest.json index a048b23..f0fbf73 100644 --- a/omarchy/smoido.claude-mode/manifest.json +++ b/omarchy/smoido.claude-mode/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "smoido.claude-mode", "name": "Claude Mode", - "version": "1.8.0", + "version": "1.10.0", "author": "smoido", "description": "Which provider Claude Code is pointed at, and a one-click switch between them", "kinds": ["bar-widget"],