Skip to content

Add searchable session tags to cmd-p workspace switcher#1089

Open
apollow wants to merge 2 commits intomanaflow-ai:mainfrom
apollow:feature/cmd-p-session-tags
Open

Add searchable session tags to cmd-p workspace switcher#1089
apollow wants to merge 2 commits intomanaflow-ai:mainfrom
apollow:feature/cmd-p-session-tags

Conversation

@apollow
Copy link

@apollow apollow commented Mar 9, 2026

Summary

  • Adds per-source tag storage on workspaces (tagsBySource) with session persistence
  • Extends cmd-p search indexer to tokenize and match workspace tags
  • Shows tag footnotes below workspace title in cmd-p rows when tags exist
  • Adds workspace.set_tags / workspace.clear_tags V2 socket commands
  • Adds set-workspace-tags / clear-workspace-tags CLI commands
  • Claude Code hooks auto-extract tags from notification content (disabled by default via cmdPSessionTags setting)
  • PII sanitization filters out UUIDs, emails, tokens, paths, and env vars from extracted tags

Test plan

  • Enable setting: defaults write com.cmuxterm.app.debug cmdPSessionTags -bool true
  • Start a Claude Code session discussing a topic (e.g. 'open claw')
  • Open cmd-p and search for that topic — verify the workspace surfaces
  • Verify tag footnotes appear below workspace titles in cmd-p results
  • Test manual tags via CLI: cmux set-workspace-tags 'auth' 'payments'
  • Test tag clearing: cmux clear-workspace-tags
  • Verify session restore preserves tags across app restart
  • Verify no workspace-color-api code is included

🤖 Generated with Claude Code


Summary by cubic

Adds searchable session tags to the cmd‑p workspace switcher so you can find workspaces by topic. Tags persist per workspace, can be set via CLI, and can be auto‑extracted from Claude Code (opt‑in).

  • New Features

    • Per-source workspace tag storage (tagsBySource) with session restore and limits (10 sources, 20 tags/source, 100 chars/tag).
    • Cmd‑p indexer tokenizes tags, adds “tag”/“topic” keywords, and shows tag footnotes under workspace titles.
    • V2 socket: workspace.set_tags and workspace.clear_tags.
    • CLI: cmux set-workspace-tags and cmux clear-workspace-tags with --source/--clear.
    • Claude Code (opt‑in via cmdPSessionTags, off by default): auto-extracts tags from notifications, clears on session end, and filters PII.
  • Bug Fixes

    • Pre‑redacts PII across full text before tokenization (UUIDs, emails, tokens, paths, env vars, long IDs).
    • Deduplicates tags and enforces deterministic tag ordering across sources.
    • Stricter input validation and clearer errors in CLI and V2 (unknown flags, --clear with tags, empty/invalid source).

Written for commit e81bcce. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Workspace tagging: add, clear, and persist tags (per-source) with optional scoping.
    • Tags searchable and visible in the command palette; influence search/ranking and fingerprints.
    • Automatic tag extraction from Claude sessions when session tagging is enabled.
    • CLI and v2 command support to set/clear workspace tags.
    • Feature flag to enable/disable Claude session tagging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel
Copy link

vercel bot commented Mar 9, 2026

@apollow is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link

coderabbitai bot commented Mar 9, 2026

📝 Walkthrough

Walkthrough

Adds per-source workspace tagging, extracts tags from Claude hook summaries, persists tags in session snapshots, threads tags into command-palette search and fingerprinting, and exposes tag management via v2 socket commands and CLI commands with a feature flag to enable session tag extraction.

Changes

Cohort / File(s) Summary
Claude tag extraction & integration
CLI/cmux.swift
Adds ClaudeHookTagExtractor; validates Claude session IDs, filters stopwords/sensitive patterns, deduplicates tags, and integrates extraction into Claude hook stop/notification flows to set/clear workspace tags scoped to claude:<sessionId>.
Workspace tagging core
Sources/Workspace.swift, Sources/SessionPersistence.swift
Adds @Published var tagsBySource: [String:[String]], setTags(_:source:), clearTags(source:), clearAllTags(), and searchTags aggregation. Persists tagsBySource in SessionWorkspaceSnapshot and restores them on session restore. Enforces per-source and global limits.
v2 socket API & CLI handlers
Sources/TerminalController.swift, CLI/...
Adds v2WorkspaceSetTags and v2WorkspaceClearTags handlers, advertises workspace.set_tags / workspace.clear_tags in v2 capabilities, and implements parameter validation and structured success/error responses. CLI commands for set-workspace-tags and clear-workspace-tags route to the same semantics.
Command palette & indexing
Sources/ContentView.swift
Threads tags: [String] through CommandPaletteCommand and CommandPaletteSwitcherSearchMetadata; indexes tag tokens via tagTokensForSearch(_:); includes tags in searchable texts, context keywords, and fingerprint hashing; UI shows tag annotations in switcher rows.
Feature flag
Sources/cmuxApp.swift
Adds ClaudeCodeIntegrationSettings.sessionTagsEnabledKey, default, and accessors to read/write a UserDefaults-backed sessionTagsEnabled flag.
sequenceDiagram
  participant ClaudeHook as Claude Hook
  participant Extractor as ClaudeHookTagExtractor
  participant Workspace as Workspace (tagsBySource)
  participant Terminal as TerminalController / v2 socket

  ClaudeHook->>Extractor: produce summary (on notify / prompt-submit)
  Extractor->>Extractor: validate sessionId, filter tokens, dedupe tags
  alt tags found and sessionTagsEnabled
    Extractor->>Workspace: setTags(tags, source: "claude:<sessionId>")
    Extractor->>Terminal: (optional) emit v2 event / CLI update
  else no tags or disabled
    Extractor->>Workspace: clearTags(source: "claude:<sessionId>")
  end
  Note right of Workspace: tags persisted to SessionWorkspaceSnapshot and used by search/indexing
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I hopped through Claude's summaries, nibbling tags anew,
Clipped the weeds, kept the jewels, labeled each cozy view.
Seeded search and saved the lot in burrows snug and deep,
Now switcher, socket, CLI sing — our tagging dreams to keep.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.65% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the main change: adding searchable session tags to the cmd-p workspace switcher, which is the core feature across all file modifications.
Description check ✅ Passed The PR description covers what changed, why, and includes a detailed test plan. However, it lacks the Testing section structure specified in the template and doesn't include a demo video or Review Trigger block.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 6 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="CLI/cmux.swift">

<violation number="1" location="CLI/cmux.swift:449">
P1: PII sanitization is ineffective: `delimiters` splits tokens on `-`, `.`, `/`, `_`, and space *before* `isSensitive()` runs, so the UUID, email, API-token, and path regexes can never match. For example, a UUID is split into five hex fragments that all pass through as tags. Either move the sensitivity check to run on the raw `combined` string before splitting (e.g., redact matches in-place), or remove conflicting characters from the delimiter set and split in two passes.</violation>
</file>

<file name="Sources/TerminalController.swift">

<violation number="1" location="Sources/TerminalController.swift:3141">
P2: `compactMap` silently drops non-string elements, so `{"tags": [1, 2]}` passes the guard but sets empty tags on the workspace. Consider validating that all elements are strings (e.g., `as? [String]`) or at minimum that the resulting array is non-empty.</violation>
</file>

<file name="Sources/Workspace.swift">

<violation number="1" location="Sources/Workspace.swift:1596">
P2: Duplicate tags are not filtered, so repeated values consume the per-source quota without adding search value. Since tags are auto-extracted from notification content, duplicates are likely. Consider deduplicating after sanitization.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

@greptile-apps
Copy link

greptile-apps bot commented Mar 9, 2026

Greptile Summary

This PR introduces searchable session tags for the cmd-p workspace switcher by adding per-source tag storage on Workspace objects, wiring two new V2 socket commands (workspace.set_tags / workspace.clear_tags), extending the cmd-p search indexer, and hooking into Claude Code notification/stop events to auto-extract and clear tags. The feature is off-by-default and session-scoped (tags are cleared when the session ends).

Key changes:

  • Workspace.tagsBySource: [String: [String]] with capacity limits (10 sources × 20 tags × 100 chars) and session persistence via SessionWorkspaceSnapshot
  • ClaudeHookTagExtractor in CLI/cmux.swift tokenizes notification content and applies a PII filter before calling workspace.set_tags
  • CommandPaletteSwitcherSearchIndexer gains tagTokensForSearch and cmd-p rows render a tag footnote when tags are present
  • Two new CLI commands: cmux set-workspace-tags and cmux clear-workspace-tags

Notable concern: The PII sensitive-pattern filter (sensitivePatterns) includes matchers for email addresses and filesystem paths, but the tokenization step (splitting on /, ., \ etc.) runs first — meaning a full email or path is never passed intact to isSensitive. This effectively disables the email and path protection patterns, potentially allowing username fragments or path components to be indexed as tags.

Confidence Score: 3/5

  • Mostly safe to merge, but the PII filter has a structural gap that should be addressed before the feature is enabled in production
  • The core plumbing (socket commands, persistence, UI rendering, CLI) is well-structured and mirrors existing patterns. The feature defaults to disabled, limiting blast radius. However, the PII sanitization in ClaudeHookTagExtractor has a logic flaw where the email and path sensitive patterns can never match after tokenization on their delimiter characters (., /), giving a false sense of security. There are also minor issues with non-deterministic tag ordering and an unbounded source key length.
  • CLI/cmux.swift — specifically the ClaudeHookTagExtractor.sensitivePatterns / extractTags interaction where tokenization defeats the email and path PII filters

Important Files Changed

Filename Overview
CLI/cmux.swift Adds ClaudeHookTagExtractor for PII-aware tag extraction from notifications, two new CLI commands (set-workspace-tags, clear-workspace-tags), and hooks into claude-hook notification/stop events to set/clear per-session tags — however the sensitive-pattern filters for emails and paths are defeated by the tokenizer's own delimiters, creating a PII leak risk.
Sources/Workspace.swift Adds tagsBySource dictionary with setTags/clearTags/clearAllTags methods, capacity limits (10 sources × 20 tags × 100 chars), and a searchTags computed property; source key has no length cap, and searchTags iteration order is non-deterministic.
Sources/ContentView.swift Extends CommandPaletteItem with a tags field, adds tag footnote rendering below workspace titles, integrates tags into CommandPaletteSwitcherSearchMetadata and CommandPaletteSwitcherSearchIndexer, and adjusts row heights; changes look correct and well-contained.
Sources/TerminalController.swift Wires up new workspace.set_tags and workspace.clear_tags V2 socket commands to v2WorkspaceSetTags/v2WorkspaceClearTags handlers; implementation mirrors existing rename pattern and looks correct.
Sources/SessionPersistence.swift Adds optional tagsBySource field to SessionWorkspaceSnapshot for session persistence; minimal and backwards-compatible change.
Sources/cmuxApp.swift Adds sessionTagsEnabled setting (defaulting to false) with getter/setter to ClaudeCodeIntegrationSettings; straightforward and consistent with existing settings pattern.

Sequence Diagram

sequenceDiagram
    participant CC as Claude Code Hook (CLI)
    participant TC as TerminalController (V2 Socket)
    participant WS as Workspace
    participant CP as cmd-p UI

    Note over CC,CP: Tag Extraction Flow (notification event)
    CC->>CC: extractTags(subtitle, body)<br/>tokenize + PII filter
    CC->>TC: workspace.set_tags {workspace_id, source, tags}
    TC->>WS: setTags(tags, source)
    WS->>WS: sanitize + store in tagsBySource
    WS-->>CP: @Published tagsBySource triggers re-render
    CP->>CP: searchTags flatMap → tag footnote + search index

    Note over CC,CP: Session Stop Flow
    CC->>TC: workspace.clear_tags {workspace_id, source}
    TC->>WS: clearTags(source)
    WS->>WS: tagsBySource.removeValue(forKey: source)

    Note over CC,CP: Manual CLI Flow
    CC->>TC: workspace.set_tags / workspace.clear_tags
    TC->>WS: setTags / clearTags / clearAllTags
Loading

Last reviewed commit: 3e99320

Comment on lines +437 to +449
private static let sensitivePatterns: [NSRegularExpression] = {
let patterns = [
"^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$",
"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$",
"^(sk-|pk-|ghp_|gho_|Bearer |token )",
"^(/|~/).+/",
"^[A-Z_]{2,}=",
"^[0-9]{10,}$"
]
return patterns.compactMap { try? NSRegularExpression(pattern: $0, options: .caseInsensitive) }
}()

private static let delimiters = CharacterSet(charactersIn: "/\\.:_- ,;()[]{}\"'`")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PII filter patterns are defeated by prior tokenization

sensitivePatterns includes patterns for email addresses (^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$) and filesystem paths (^(/|~/).+/), but the delimiters CharacterSet on line 449 contains ., /, and \. This means that before isSensitive is ever called, emails and paths are already split into sub-tokens that individually do not match these patterns.

Concrete example:

  • Input notification body: "Edit /home/john@company.com/secrets"
  • After components(separatedBy: delimiters): ["Edit", "home", "john@company", "com", "secrets"]
  • "john@company" is tested against the email regex — it has no TLD, so it does not match and passes through as a tag
  • "home", "secrets" also pass through

The email pattern would only match a complete user@domain.tld token, which can never exist after splitting on .. Similarly, the path pattern ^(/|~/).+/ requires a leading / and a trailing /, but / is a delimiter so no token can start with /. Both these sensitive patterns are effectively dead code.

Consider applying isSensitive to the raw combined string before tokenizing (or constructing patterns around the token-split fragments, e.g., checking for ^[a-zA-Z0-9._%+-]+@ to catch partial emails).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed — pre-redact sensitive spans on the full combined string before tokenization using unanchored patterns, so multi-part tokens like UUIDs/emails/paths are caught before splitting.

Comment on lines +1591 to +1593
var searchTags: [String] {
tagsBySource.values.flatMap { $0 }
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-deterministic tag display order

Dictionary.values has no guaranteed iteration order in Swift, so tagsBySource.values.flatMap { $0 } produces tags in a different order each time the dictionary is mutated (e.g., between app launches or after any setTags call). This means the tag footnote rendered in cmd-p rows can shuffle unpredictably.

Consider sorting by source key to get a stable, deterministic order:

Suggested change
var searchTags: [String] {
tagsBySource.values.flatMap { $0 }
}
var searchTags: [String] {
tagsBySource.keys.sorted().flatMap { tagsBySource[$0] ?? [] }
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed — searchTags now iterates keys in sorted order and deduplicates via seen-set for deterministic output.

Comment on lines +1595 to +1611
func setTags(_ tags: [String], source: String) {
let sanitized = tags
.map { String($0.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Self.maxTagLength)) }
.filter { !$0.isEmpty }
.prefix(Self.maxTagsPerSource)
var updated = tagsBySource
updated[source] = Array(sanitized)
if updated.count > Self.maxTagSources {
let sortedKeys = updated.keys.sorted()
for key in sortedKeys {
if key != source, updated.count > Self.maxTagSources {
updated.removeValue(forKey: key)
}
}
}
tagsBySource = updated
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

source key has no length bound

Individual tag strings are capped at maxTagLength (100 chars), but the source key stored in tagsBySource has no length validation. A caller (e.g. via the CLI --source flag or an automated hook) could pass an arbitrarily long source key, which would be stored in the dictionary indefinitely and serialized to the session snapshot.

Consider adding a length check:

Suggested change
func setTags(_ tags: [String], source: String) {
let sanitized = tags
.map { String($0.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Self.maxTagLength)) }
.filter { !$0.isEmpty }
.prefix(Self.maxTagsPerSource)
var updated = tagsBySource
updated[source] = Array(sanitized)
if updated.count > Self.maxTagSources {
let sortedKeys = updated.keys.sorted()
for key in sortedKeys {
if key != source, updated.count > Self.maxTagSources {
updated.removeValue(forKey: key)
}
}
}
tagsBySource = updated
}
func setTags(_ tags: [String], source: String) {
let trimmedSource = String(source.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Self.maxTagLength))
guard !trimmedSource.isEmpty else { return }
let sanitized = tags
.map { String($0.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Self.maxTagLength)) }
.filter { !$0.isEmpty }
.prefix(Self.maxTagsPerSource)
var updated = tagsBySource
updated[trimmedSource] = Array(sanitized)
if updated.count > Self.maxTagSources {
let sortedKeys = updated.keys.sorted()
for key in sortedKeys {
if key != trimmedSource, updated.count > Self.maxTagSources {
updated.removeValue(forKey: key)
}
}
}
tagsBySource = updated
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed — added maxSourceLength = 200 constant, source strings are truncated in setTags.

Comment on lines +6384 to +6390
if let sessionId = parsedInput.sessionId ?? consumedSession?.sessionId,
ClaudeHookTagExtractor.isValidSessionId(sessionId) {
_ = try? client.sendV2(method: "workspace.clear_tags", params: [
"workspace_id": workspaceId,
"source": "claude:\(sessionId)"
])
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tags cleared unconditionally on session stop regardless of feature flag

The workspace.clear_tags call fires for every stop/idle event even when cmdPSessionTags is disabled. When the feature is turned off, the notification handler never sets tags (it checks isSessionTagsEnabled()), so this clear is a no-op network round-trip for every session stop.

This is not a correctness bug (it's safe to clear tags that don't exist), but it's slightly wasteful. Consider guarding this with the same isSessionTagsEnabled() check used in the notification handler:

Suggested change
if let sessionId = parsedInput.sessionId ?? consumedSession?.sessionId,
ClaudeHookTagExtractor.isValidSessionId(sessionId) {
_ = try? client.sendV2(method: "workspace.clear_tags", params: [
"workspace_id": workspaceId,
"source": "claude:\(sessionId)"
])
}
if let sessionId = parsedInput.sessionId ?? consumedSession?.sessionId,
ClaudeHookTagExtractor.isValidSessionId(sessionId),
ClaudeHookTagExtractor.isSessionTagsEnabled() {
_ = try? client.sendV2(method: "workspace.clear_tags", params: [
"workspace_id": workspaceId,
"source": "claude:\(sessionId)"
])
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed — gated the clear_tags call in claude-hook stop behind isSessionTagsEnabled() check.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (1)
Sources/ContentView.swift (1)

1449-1477: Keep tags in the command model’s searchable surface.

CommandPaletteCommand.tags currently affects rendering only. Search still depends on each caller remembering to duplicate those values into keywords, which is easy to miss the next time a tagged command type is added.

Suggested fix
         var searchableTexts: [String] {
-            [title, subtitle] + keywords
+            [title, subtitle] + keywords + tags
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 1449 - 1477, The searchable surface
is missing tags, so update the CommandPaletteCommand model by including its tags
in the searchableTexts computed property (in addition to title, subtitle and
keywords) so tagged commands are found by search; locate the searchableTexts
property and append the tags array to the returned array (ensure tags is
included in the concatenation with [title, subtitle] + keywords + tags).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CLI/cmux.swift`:
- Around line 449-479: extractTags currently tokenizes the combined text then
filters tokens with isSensitive, which lets sensitive substrings (paths, env
vars) leak after splitting; instead, pre-redact sensitive spans on the full
combined string before tokenization: in extractTags (and using
sensitivePatterns/isSensitive) scan the combined = "\(body) \(subtitle)" and
replace any matches of sensitivePatterns (e.g., path, env var regexes) with a
placeholder or remove them, then tokenize the redacted combined string using
delimiters and continue with the existing trimming/stopword/length de-dup logic;
keep the sensitivePatterns and isSensitive helpers but use them to perform
redaction up-front.
- Around line 1450-1467: The command currently allows --clear alongside
positional tags (isClear, rem2, tagArgs); change the logic so if isClear is true
and there are any non-empty positional tags (compute tagArgs from rem2 as
currently done), throw a CLIError indicating that --clear is mutually exclusive
with tags instead of proceeding to clear; update the error message (e.g., in the
set-workspace-tags handling) and perform this check before calling
client.sendV2/ workspace.clear_tags.
- Around line 6463-6473: The code currently only sends workspace.set_tags when
ClaudeHookTagExtractor.extractTags(...) yields non-empty tags, so previously-set
Claude tags persist; modify the block around
ClaudeHookTagExtractor.extractTags(subtitle:body:) so that after extracting tags
you always call client.sendV2(method: "workspace.set_tags", params:
["workspace_id": workspaceId, "source": "claude:\(sessionId)", "tags": tags]) —
i.e., if tags.isEmpty send the same API call with an empty tags array to clear
the Claude source for that session; keep using
ClaudeHookTagExtractor.isValidSessionId(sessionId) and isSessionTagsEnabled()
guards and reuse workspaceId, sessionId, client.sendV2 identifiers.
- Around line 1477-1486: The clear-workspace-tags branch currently ignores
leftover argv (variables rem0) so stray arguments like "extra" are accepted;
after parsing options (the parseOption calls that produce wsArg, rem0 and
srcArg), validate that rem0 contains no additional non-empty tokens and if it
does, return a usage/error (fail fast) before calling resolveWorkspaceId and
client.sendV2; update the "clear-workspace-tags" case to check rem0.isEmpty (or
equivalent) and emit a clear error/exit when extra args are present to prevent
accidental destructive actions.

In `@Sources/ContentView.swift`:
- Around line 6055-6057: The code is unconditionally adding "claude" when any
tagTokens exist, causing false matches; update the block that checks tagTokens
so it no longer appends "claude" unconditionally (keep "tag" and "topic"), and
instead only append "claude" when the tag provenance indicates it was created by
Claude (e.g., check the tag/source metadata used in your tag model or token
generation and append "claude" only when that metadata equals the Claude
origin); update the logic around tagTokens and contextKeywords to reflect this
conditional append.

In `@Sources/TerminalController.swift`:
- Around line 3138-3141: The handler currently uses compactMap on params["tags"]
(tagsRaw) which silently accepts mixed-type arrays (e.g., ["foo", 123]); change
validation to reject the entire request if any element is not a String (return
.err with an "invalid_params" message like "tags must be an array of strings"),
then convert to [String] only after validation and call Workspace.setTags as
before but return the canonical tags from the workspace (the
persisted/normalized/trimmed result) instead of echoing the input; apply the
same strict validation-and-return-the-canonical-tags change to the other tags
handling block at the indicated region (the code around Workspace.setTags).
- Around line 3175-3184: The code currently treats a malformed or blank "source"
as absent because v2String(params, "source") returns nil, which causes
workspace.clearAllTags() to run; change the logic to first check whether the
"source" key is present (e.g. using the existing v2HasKey or equivalent), and if
present but v2String returns nil, return an "invalid_params" error instead of
calling clearAllTags(); only call workspace.clearTags(source:) when v2String
returns a non-nil String, and call workspace.clearAllTags() only when the
"source" key is truly absent — update the block inside v2MainSync accordingly
(referencing v2String, v2HasKey, v2MainSync, workspace.clearTags and
workspace.clearAllTags).

In `@Sources/Workspace.swift`:
- Around line 1595-1611: In setTags(_:source:) sanitize and limit the incoming
tags as currently done, but if the resulting sanitized collection is empty treat
this as a clear operation: do not store an empty array for the given source in
tagsBySource (remove the key from updated or skip setting it) so empty writes
don't count toward Self.maxTagSources or get serialized; otherwise continue to
set updated[source] = Array(sanitized) and apply the existing eviction logic
that uses Self.maxTagSources, maxTagsPerSource and maxTagLength.
- Around line 197-199: The snapshot restoration currently assigns
snapshot.tagsBySource directly to tagsBySource, bypassing normalization and
failing to clear tags when the snapshot value is nil; instead iterate
snapshot.tagsBySource (for each source,keyedTags) and call setTags(_:source:) to
apply trimming/casing rules and ensure normalization, and when
snapshot.tagsBySource is nil or a source is missing, call setTags([], source:)
or otherwise clear that source so stale in-memory tags are removed; update the
restore path around tagsBySource and reference the tagsBySource property and
setTags(_:source:) method to implement this behavior.
- Around line 1591-1593: The computed property searchTags currently flattens
tagsBySource which yields non-deterministic order and duplicates; update
searchTags to collect all tags from tagsBySource.values, deduplicate them (e.g.,
via a Set) and then return a deterministically ordered array (e.g., sorted()) so
the command-palette fingerprint is stable; reference the searchTags property and
tagsBySource dictionary when applying this change.

---

Nitpick comments:
In `@Sources/ContentView.swift`:
- Around line 1449-1477: The searchable surface is missing tags, so update the
CommandPaletteCommand model by including its tags in the searchableTexts
computed property (in addition to title, subtitle and keywords) so tagged
commands are found by search; locate the searchableTexts property and append the
tags array to the returned array (ensure tags is included in the concatenation
with [title, subtitle] + keywords + tags).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b4fb3a62-3392-4c0b-9d8d-84c9d7de2d82

📥 Commits

Reviewing files that changed from the base of the PR and between 0f29735 and 3e99320.

📒 Files selected for processing (6)
  • CLI/cmux.swift
  • Sources/ContentView.swift
  • Sources/SessionPersistence.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
  • Sources/cmuxApp.swift

…tion, deterministic ordering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/Workspace.swift">

<violation number="1" location="Sources/Workspace.swift:1609">
P2: Truncating the `source` key in `setTags` breaks readback/clear behavior for long source values because other paths still use the untruncated key.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

}

func setTags(_ tags: [String], source: String) {
let truncatedSource = String(source.prefix(Self.maxSourceLength))
Copy link

@cubic-dev-ai cubic-dev-ai bot Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Truncating the source key in setTags breaks readback/clear behavior for long source values because other paths still use the untruncated key.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Workspace.swift, line 1609:

<comment>Truncating the `source` key in `setTags` breaks readback/clear behavior for long source values because other paths still use the untruncated key.</comment>

<file context>
@@ -1587,22 +1590,44 @@ final class Workspace: Identifiable, ObservableObject {
-            .map { String($0.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Self.maxTagLength)) }
-            .filter { !$0.isEmpty }
-            .prefix(Self.maxTagsPerSource)
+        let truncatedSource = String(source.prefix(Self.maxSourceLength))
+        var seen = Set<String>()
+        var deduped = [String]()
</file context>
Fix with Cubic

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
Sources/Workspace.swift (1)

197-202: ⚠️ Potential issue | 🟡 Minor

Stale in-memory tags persist when snapshot lacks tag data.

When snapshot.tagsBySource is nil (e.g., restoring an older snapshot format), the entire block is skipped, leaving any existing in-memory tags untouched. This can cause stale tags from a previous session to linger.

Consider unconditionally clearing tags, then restoring if present:

💡 Suggested fix
-        if let restoredTags = snapshot.tagsBySource {
-            tagsBySource = [:]
-            for (source, tags) in restoredTags {
-                setTags(tags, source: source)
-            }
-        }
+        tagsBySource = [:]
+        if let restoredTags = snapshot.tagsBySource {
+            for source in restoredTags.keys.sorted() {
+                setTags(restoredTags[source] ?? [], source: source)
+            }
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 197 - 202, Clear the in-memory tags
unconditionally before attempting to restore from the snapshot so stale tags
don't persist when snapshot.tagsBySource is nil; specifically, move the
tagsBySource = [:] (or call whatever clear/reset method for tags) outside the
if-let block and then, if snapshot.tagsBySource is present, iterate and call
setTags(_:source:) to restore them (referencing snapshot.tagsBySource,
tagsBySource, and setTags(_:source:)).
🧹 Nitpick comments (1)
Sources/ContentView.swift (1)

1475-1476: Avoid double-indexing raw tags in the search corpus.

For switcher entries, keywords already include the raw tag plus tokenized components via CommandPaletteSwitcherSearchIndexer.tagTokensForSearch(_). Appending tags again here doesn’t improve matching, but it does increase normalizedSearchableTexts and fuzzy-match work on every query.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 1475 - 1476, The searchableTexts
computed property currently returns [title, subtitle] + keywords + tags which
double-includes raw tag strings because keywords already contains raw tags and
their tokenized components (built by
CommandPaletteSwitcherSearchIndexer.tagTokensForSearch(_:)); remove the appended
tags from searchableTexts so it becomes [title, subtitle] + keywords to avoid
duplicate indexing and excess work during normalizedSearchableTexts/fuzzy
matching.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CLI/cmux.swift`:
- Around line 6415-6422: The cleanup code should always call
workspace.clear_tags when a valid sessionId exists; remove the gate on
ClaudeHookTagExtractor.isSessionTagsEnabled() so that if parsedInput.sessionId
?? consumedSession?.sessionId yields a sessionId and
ClaudeHookTagExtractor.isValidSessionId(sessionId) is true, you unconditionally
call client.sendV2(method: "workspace.clear_tags", params: ["workspace_id":
workspaceId, "source": "claude:\(sessionId)"]) (keep the existing try?
behavior), ensuring stale claude:<sessionId> tags are always cleared on
stop/idle.
- Around line 1470-1489: The code currently computes isClear and scans rem2
without honoring a "--" terminator; update the logic around isClear/rem2/tagArgs
so tokens after the first "--" are treated as positional-only: split rem1 at the
first "--" into beforeTerm and afterTerm (preserve afterTerm as literal
positional tags), determine isClear and check unknown "--*" flags only within
beforeTerm, build positionalTags/tagArgs by combining afterTerm with non-flag,
non-empty tokens from beforeTerm (or simply using afterTerm when "--" is
present), and then proceed to call client.sendV2 or construct tags using the
tagArgs variable; adjust the checks that throw CLIError to reference beforeTerm
instead of the full rem1/rem2.

In `@Sources/ContentView.swift`:
- Around line 3077-3078: The tag separator is currently a hardcoded literal in
the Text created from result.command.tags.joined(separator: " · "); update the
join to use a localized separator by creating a localized string (via
String(localized: "tag.separator", defaultValue: " · ")) and pass that as the
separator to result.command.tags.joined(...), ensuring the Text(...) uses the
localized-joined string instead of a bare literal; add the "tag.separator" key
to localization resources as needed.

In `@Sources/Workspace.swift`:
- Around line 1638-1640: clearTags currently uses the raw source string key
while setTags truncates/normalizes sources to maxSourceLength before storing,
causing mismatched keys; update clearTags to apply the same
truncation/normalization logic (using maxSourceLength and the same normalization
routine used by setTags) before calling tagsBySource.removeValue(forKey:),
referencing the clearTags and setTags functions and the maxSourceLength constant
so the key lookup/removal matches stored entries.

---

Duplicate comments:
In `@Sources/Workspace.swift`:
- Around line 197-202: Clear the in-memory tags unconditionally before
attempting to restore from the snapshot so stale tags don't persist when
snapshot.tagsBySource is nil; specifically, move the tagsBySource = [:] (or call
whatever clear/reset method for tags) outside the if-let block and then, if
snapshot.tagsBySource is present, iterate and call setTags(_:source:) to restore
them (referencing snapshot.tagsBySource, tagsBySource, and setTags(_:source:)).

---

Nitpick comments:
In `@Sources/ContentView.swift`:
- Around line 1475-1476: The searchableTexts computed property currently returns
[title, subtitle] + keywords + tags which double-includes raw tag strings
because keywords already contains raw tags and their tokenized components (built
by CommandPaletteSwitcherSearchIndexer.tagTokensForSearch(_:)); remove the
appended tags from searchableTexts so it becomes [title, subtitle] + keywords to
avoid duplicate indexing and excess work during normalizedSearchableTexts/fuzzy
matching.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aac0f732-c444-4444-baf2-efcfa5250895

📥 Commits

Reviewing files that changed from the base of the PR and between 3e99320 and e81bcce.

📒 Files selected for processing (4)
  • CLI/cmux.swift
  • Sources/ContentView.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift

Comment on lines +1470 to +1489
let isClear = rem1.contains("--clear")
let rem2 = rem1.filter { $0 != "--clear" }
if let unknownFlag = rem2.first(where: { $0.hasPrefix("--") && $0 != "--" }) {
throw CLIError(message: "set-workspace-tags: unknown flag '\(unknownFlag)'")
}
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
let source = srcArg ?? "manual"
let positionalTags = rem2.filter { !$0.hasPrefix("--") && !$0.isEmpty }
if isClear && !positionalTags.isEmpty {
throw CLIError(message: "set-workspace-tags: --clear cannot be combined with positional tag arguments")
}
if isClear {
let payload = try client.sendV2(method: "workspace.clear_tags", params: [
"workspace_id": wsId,
"source": source
])
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
} else {
let tagArgs = rem2.dropFirst(rem2.first == "--" ? 1 : 0)
let tags = Array(tagArgs).filter { !$0.isEmpty }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Honor -- before treating --clear as a flag.

This branch still scans the entire remainder for --clear and unknown --* tokens, even after a -- terminator. That means cmux set-workspace-tags -- --clear clears tags instead of setting a literal tag, and ---prefixed tags can never be passed at all.

Suggested fix
-            let isClear = rem1.contains("--clear")
-            let rem2 = rem1.filter { $0 != "--clear" }
-            if let unknownFlag = rem2.first(where: { $0.hasPrefix("--") && $0 != "--" }) {
+            let terminatorIndex = rem1.firstIndex(of: "--")
+            let preTerminator = terminatorIndex.map { Array(rem1[..<$0]) } ?? rem1
+            let postTerminator = terminatorIndex.map { Array(rem1[rem1.index(after: $0)...]) } ?? []
+
+            let isClear = preTerminator.contains("--clear")
+            let rem2 = preTerminator.filter { $0 != "--clear" }
+            if let unknownFlag = rem2.first(where: { $0.hasPrefix("--") }) {
                 throw CLIError(message: "set-workspace-tags: unknown flag '\(unknownFlag)'")
             }
             let wsId = try resolveWorkspaceId(workspaceArg, client: client)
             let source = srcArg ?? "manual"
-            let positionalTags = rem2.filter { !$0.hasPrefix("--") && !$0.isEmpty }
+            let positionalTags =
+                rem2.filter { !$0.isEmpty } +
+                postTerminator.filter { !$0.isEmpty }
             if isClear && !positionalTags.isEmpty {
                 throw CLIError(message: "set-workspace-tags: --clear cannot be combined with positional tag arguments")
             }
             if isClear {
                 let payload = try client.sendV2(method: "workspace.clear_tags", params: [
@@
             } else {
-                let tagArgs = rem2.dropFirst(rem2.first == "--" ? 1 : 0)
-                let tags = Array(tagArgs).filter { !$0.isEmpty }
+                let tags = positionalTags
                 guard !tags.isEmpty else {
                     throw CLIError(message: "set-workspace-tags requires at least one tag or --clear")
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 1470 - 1489, The code currently computes isClear
and scans rem2 without honoring a "--" terminator; update the logic around
isClear/rem2/tagArgs so tokens after the first "--" are treated as
positional-only: split rem1 at the first "--" into beforeTerm and afterTerm
(preserve afterTerm as literal positional tags), determine isClear and check
unknown "--*" flags only within beforeTerm, build positionalTags/tagArgs by
combining afterTerm with non-flag, non-empty tokens from beforeTerm (or simply
using afterTerm when "--" is present), and then proceed to call client.sendV2 or
construct tags using the tagArgs variable; adjust the checks that throw CLIError
to reference beforeTerm instead of the full rem1/rem2.

Comment on lines +6415 to +6422
if let sessionId = parsedInput.sessionId ?? consumedSession?.sessionId,
ClaudeHookTagExtractor.isValidSessionId(sessionId),
ClaudeHookTagExtractor.isSessionTagsEnabled() {
_ = try? client.sendV2(method: "workspace.clear_tags", params: [
"workspace_id": workspaceId,
"source": "claude:\(sessionId)"
])
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Always clear Claude session tags on stop.

Cleanup is incorrectly gated on isSessionTagsEnabled(). If a session generated tags while the setting was on and the user disables it before stop/idle, this branch skips workspace.clear_tags, so stale claude:<sessionId> tags remain searchable after the session ends.

Suggested fix
-            if let sessionId = parsedInput.sessionId ?? consumedSession?.sessionId,
-               ClaudeHookTagExtractor.isValidSessionId(sessionId),
-               ClaudeHookTagExtractor.isSessionTagsEnabled() {
+            if let sessionId = parsedInput.sessionId ?? consumedSession?.sessionId,
+               ClaudeHookTagExtractor.isValidSessionId(sessionId) {
                 _ = try? client.sendV2(method: "workspace.clear_tags", params: [
                     "workspace_id": workspaceId,
                     "source": "claude:\(sessionId)"
                 ])
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if let sessionId = parsedInput.sessionId ?? consumedSession?.sessionId,
ClaudeHookTagExtractor.isValidSessionId(sessionId),
ClaudeHookTagExtractor.isSessionTagsEnabled() {
_ = try? client.sendV2(method: "workspace.clear_tags", params: [
"workspace_id": workspaceId,
"source": "claude:\(sessionId)"
])
}
if let sessionId = parsedInput.sessionId ?? consumedSession?.sessionId,
ClaudeHookTagExtractor.isValidSessionId(sessionId) {
_ = try? client.sendV2(method: "workspace.clear_tags", params: [
"workspace_id": workspaceId,
"source": "claude:\(sessionId)"
])
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 6415 - 6422, The cleanup code should always call
workspace.clear_tags when a valid sessionId exists; remove the gate on
ClaudeHookTagExtractor.isSessionTagsEnabled() so that if parsedInput.sessionId
?? consumedSession?.sessionId yields a sessionId and
ClaudeHookTagExtractor.isValidSessionId(sessionId) is true, you unconditionally
call client.sendV2(method: "workspace.clear_tags", params: ["workspace_id":
workspaceId, "source": "claude:\(sessionId)"]) (keep the existing try?
behavior), ensuring stale claude:<sessionId> tags are always cleared on
stop/idle.

Comment on lines +3077 to +3078
if !result.command.tags.isEmpty {
Text(result.command.tags.joined(separator: " · "))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Localize the new tag separator.

This adds a new user-visible literal inside a SwiftUI Text. Please route the separator through String(localized:..., defaultValue:...) too.

🌐 Suggested fix
-                                        Text(result.command.tags.joined(separator: " · "))
+                                        Text(
+                                            result.command.tags.joined(
+                                                separator: String(
+                                                    localized: "commandPalette.switcher.tagsSeparator",
+                                                    defaultValue: " · "
+                                                )
+                                            )
+                                        )

As per coding guidelines "All user-facing strings must be localized using String(localized: "key.name", defaultValue: "English text"); never use bare string literals in SwiftUI Text(), Button(), alert titles, or UI elements"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 3077 - 3078, The tag separator is
currently a hardcoded literal in the Text created from
result.command.tags.joined(separator: " · "); update the join to use a localized
separator by creating a localized string (via String(localized: "tag.separator",
defaultValue: " · ")) and pass that as the separator to
result.command.tags.joined(...), ensuring the Text(...) uses the
localized-joined string instead of a bare literal; add the "tag.separator" key
to localization resources as needed.

Comment on lines +1638 to +1640
func clearTags(source: String) {
tagsBySource.removeValue(forKey: source)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

clearTags should truncate source to match setTags key normalization.

setTags truncates the source to maxSourceLength before storing, but clearTags uses the raw source string. If a caller sets tags with a source exceeding 200 characters and later calls clearTags with the same original source string, the removal will fail because the stored key is truncated.

💡 Suggested fix
 func clearTags(source: String) {
-    tagsBySource.removeValue(forKey: source)
+    let truncatedSource = String(source.prefix(Self.maxSourceLength))
+    tagsBySource.removeValue(forKey: truncatedSource)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 1638 - 1640, clearTags currently uses
the raw source string key while setTags truncates/normalizes sources to
maxSourceLength before storing, causing mismatched keys; update clearTags to
apply the same truncation/normalization logic (using maxSourceLength and the
same normalization routine used by setTags) before calling
tagsBySource.removeValue(forKey:), referencing the clearTags and setTags
functions and the maxSourceLength constant so the key lookup/removal matches
stored entries.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant