Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,16 @@ object TranscriptSerializer {
})
}

fun compactSummary(sessionId: String, uuid: String, timestamp: Long): JSONObject {
return build("compact-summary", sessionId, uuid, timestamp, JSONObject())
fun compactSummary(
sessionId: String,
uuid: String,
timestamp: Long,
summary: String,
): JSONObject {
val data = JSONObject().apply {
if (summary.isNotBlank()) put("summary", summary)
}
return build("compact-summary", sessionId, uuid, timestamp, data)
}

/** streamingText is a custom event — not part of the desktop protocol. Keep flat. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,14 @@ sealed class TranscriptEvent {
/** Canonical compaction-complete signal: emitted when Claude Code writes
* a {type:"user", isCompactSummary:true} entry. Covers both in-session
* /compact (appends to same JSONL, so shrink cannot fire) and
* resume-from-summary (first entry of a new JSONL). */
* resume-from-summary (first entry of a new JSONL).
*
* `summary` is the full text CC wrote, pre-stripped of system tags. Used
* by the renderer to offer click-to-expand on the otherwise-thin marker. */
data class CompactSummary(
override val sessionId: String,
override val uuid: String,
override val timestamp: Long,
val summary: String = "",
) : TranscriptEvent()
}
35 changes: 26 additions & 9 deletions app/src/main/kotlin/com/youcoded/app/parser/TranscriptWatcher.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,19 @@ class TranscriptWatcher(

/** Strip internal XML tags and ANSI escapes that should never appear in rendered content.
* - system-reminder: injected system context
* - command-name/message/args: slash-command metadata (strip entirely)
* - local-command-stdout/stderr: command output (keep inner text)
* - command-name/message/args: slash-command metadata
* - local-command-stdout/stderr: dimmed CC echoes of slash-command output
* (unwrapping them surfaced "Compacted (ctrl+o to see full summary)"
* as a fake user bubble after every /compact AND set isThinking:true
* with nothing to clear it — both stripped now to match desktop parity).
*/
private val STRIP_ENTIRELY_REGEX = Regex(
"""<(system-reminder|command-name|command-message|command-args)>[\s\S]*?</\1>""",
)
private val UNWRAP_REGEX = Regex(
"""<(local-command-stdout|local-command-stderr)>([\s\S]*?)</\1>""",
"""<(system-reminder|command-name|command-message|command-args|local-command-stdout|local-command-stderr)>[\s\S]*?</\1>""",
)
private val ANSI_REGEX = Regex("""\u001b\[[0-9;]*[a-zA-Z]""")

fun stripSystemTags(text: String): String {
var result = STRIP_ENTIRELY_REGEX.replace(text, "")
result = UNWRAP_REGEX.replace(result) { it.groupValues[2] }
result = ANSI_REGEX.replace(result, "")
return result.trim()
}
Expand Down Expand Up @@ -240,9 +239,27 @@ class TranscriptWatcher(
// Compact-summary entry — canonical "compaction finished" signal.
// Written after /compact (appended to same JSONL) or resume-from-summary
// (first entry of new JSONL). isVisibleInTranscriptOnly=true: suppress
// from chat timeline and emit the dedicated signal instead.
// from chat timeline and emit the dedicated signal instead. Also
// forward the summary text so the chat-side marker can click-to-expand.
if (obj.optBoolean("isCompactSummary", false)) {
_events.tryEmit(TranscriptEvent.CompactSummary(sessionId, uuid, timestamp))
val msg = obj.optJSONObject("message")
val rawContent = when (val c = msg?.opt("content")) {
is String -> c
is JSONArray -> {
val sb = StringBuilder()
for (i in 0 until c.length()) {
val block = c.optJSONObject(i) ?: continue
if (block.optString("type") == "text") {
if (sb.isNotEmpty()) sb.append('\n')
sb.append(block.optString("text", ""))
}
}
sb.toString()
}
else -> ""
}
val summary = stripSystemTags(rawContent)
_events.tryEmit(TranscriptEvent.CompactSummary(sessionId, uuid, timestamp, summary))
return
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ class ManagedSession(
anthropicRequestId = event.anthropicRequestId,
)
is TranscriptEvent.StreamingText -> TranscriptSerializer.streamingText(event.sessionId, event.text)
is TranscriptEvent.CompactSummary -> TranscriptSerializer.compactSummary(event.sessionId, event.uuid, event.timestamp)
is TranscriptEvent.CompactSummary -> TranscriptSerializer.compactSummary(event.sessionId, event.uuid, event.timestamp, event.summary)
}
server.broadcast(JSONObject().apply {
put("type", "transcript:event")
Expand Down
71 changes: 71 additions & 0 deletions desktop/src/main/transcript-watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,77 @@ describe('transcript-watcher interrupt detection', () => {
});
});

// Regression tests for the post-/compact bugs:
// (1) a "Compacted (ctrl+o to see full summary)" user bubble appearing
// after every /compact, and
// (2) chat view permanently stuck in "thinking" state after every /compact.
// Both stemmed from the parser UNWRAPPING <local-command-stdout>, which let CC's
// dimmed stdout echo of /compact reach the reducer's TRANSCRIPT_USER_MESSAGE
// "no pending match" path — appending a fake user bubble AND setting
// isThinking:true with no transcript turn to ever clear it.
describe('transcript-watcher local-command-stdout suppression', () => {
it('emits NO events for the dimmed "Compacted (...)" stdout echo after /compact', () => {
const line = JSON.stringify({
type: 'user',
promptId: 'pid-1',
uuid: 'u-compact-stdout',
timestamp: '2026-04-29T05:29:38.779Z',
message: {
role: 'user',
// Exact shape CC v2.1.119 writes — ANSI [2m dim wrap around the inner text.
content: '<local-command-stdout>Compacted (ctrl+o to see full summary)</local-command-stdout>',
},
});
const events = parseTranscriptLine(line, 'sess-1');
expect(events).toEqual([]);
});

it('strips <local-command-stderr> the same way', () => {
const line = JSON.stringify({
type: 'user',
promptId: 'pid-1',
uuid: 'u-stderr',
message: { role: 'user', content: '<local-command-stderr>boom</local-command-stderr>' },
});
expect(parseTranscriptLine(line, 'sess-1')).toEqual([]);
});
});

describe('transcript-watcher compact-summary forwarding', () => {
it('emits compact-summary with the summary text from an isCompactSummary line', () => {
const summary = 'Summary:\n1. Primary Request: do the thing\n2. Files: foo.ts';
const line = JSON.stringify({
type: 'user',
isCompactSummary: true,
isVisibleInTranscriptOnly: true,
uuid: 'u-compact',
timestamp: '2026-04-29T05:29:38.615Z',
message: { role: 'user', content: summary },
});
const events = parseTranscriptLine(line, 'sess-1');
expect(events).toEqual([
expect.objectContaining({
type: 'compact-summary',
sessionId: 'sess-1',
data: { summary },
}),
]);
});

it('omits summary field on an isCompactSummary line with empty content', () => {
const line = JSON.stringify({
type: 'user',
isCompactSummary: true,
uuid: 'u-empty',
message: { role: 'user', content: '' },
});
const events = parseTranscriptLine(line, 'sess-1');
expect(events).toHaveLength(1);
expect(events[0].type).toBe('compact-summary');
expect(events[0].data).toEqual({});
});
});

describe('cwdToProjectSlug', () => {
it('encodes a Windows path without spaces', () => {
expect(cwdToProjectSlug('C:\\Users\\alice\\repo')).toBe('C--Users-alice-repo');
Expand Down
25 changes: 19 additions & 6 deletions desktop/src/main/transcript-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,18 @@ export function parseTranscriptLine(line: string, sessionId: string): Transcript
// isVisibleInTranscriptOnly=true means it's meant to stay hidden from UI —
// we suppress the user-message event and emit a dedicated signal that
// App.tsx uses to clear compactionPending and finalize the marker.
// Also forward the summary text so the marker can be expanded inline.
if (parsed.isCompactSummary) {
const summaryRaw = typeof content === 'string'
? content
: extractTextFromBlocks(content);
const summary = stripSystemTags(summaryRaw);
events.push({
type: 'compact-summary',
sessionId,
uuid,
timestamp,
data: {},
data: summary ? { summary } : {},
});
return events;
}
Expand Down Expand Up @@ -275,17 +280,25 @@ function extractTextFromBlocks(content: any): string {
* the chat timeline. These are injected by Claude Code's harness and
* aren't part of the assistant's actual response.
* - Tags stripped entirely: system-reminder, task-notification, antml_thinking,
* command-name, command-message, command-args
* - Tags unwrapped (inner text kept): local-command-stdout, local-command-stderr
* command-name, command-message, command-args,
* local-command-stdout, local-command-stderr
*
* Why local-command-stdout/stderr are stripped (not unwrapped): CC writes
* these as dimmed (ANSI [2m) echoes of slash-command output, e.g.
* "Compacted (ctrl+o to see full summary)" after /compact. Unwrapping them
* surfaced the dim text as a user-typed bubble AND tripped the
* TRANSCRIPT_USER_MESSAGE "no pending match" path, which set isThinking: true
* with no transcript turn to ever clear it — leaving chat permanently stuck
* in "thinking" after every compaction. The chat is the canonical
* conversation; the terminal pane already shows raw CC output for users who
* want to see slash-command stdout.
*/
const STRIP_ENTIRELY_RE = /<(task-notification|system-reminder|antml_thinking|command-name|command-message|command-args)>[\s\S]*?<\/\1>/g;
const UNWRAP_RE = /<(local-command-stdout|local-command-stderr)>([\s\S]*?)<\/\1>/g;
const STRIP_ENTIRELY_RE = /<(task-notification|system-reminder|antml_thinking|command-name|command-message|command-args|local-command-stdout|local-command-stderr)>[\s\S]*?<\/\1>/g;
const ANSI_RE = /\u001b\[[0-9;]*[a-zA-Z]/g;

function stripSystemTags(text: string): string {
return text
.replace(STRIP_ENTIRELY_RE, '')
.replace(UNWRAP_RE, (_match, _tag, inner) => inner)
.replace(ANSI_RE, '')
.trim();
}
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,10 @@ function AppInner() {
sessionId: event.sessionId,
markerId: `compact-done-${Date.now()}`,
afterContextTokens: contextTokens,
// Forward CC's summary text so the SystemMarker can offer
// click-to-expand (replaces the dead "ctrl+o to see full summary"
// affordance from CC's TUI, which never worked inside YouCoded).
...(event.data.summary ? { summary: event.data.summary } : {}),
});
}
break;
Expand Down
65 changes: 57 additions & 8 deletions desktop/src/renderer/components/SystemMarker.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,73 @@
import React from 'react';
import React, { useState } from 'react';
import type { SystemMarker as SystemMarkerData } from '../state/chat-types';

// Thin horizontal divider used for /clear and /compact markers. Permanent —
// user sees "these messages end here" when scrolling back. Deliberately quiet
// styling so it doesn't compete with actual conversation content.
//
// When a /compact marker carries a `summary` (CC-produced conversation summary
// from the isCompactSummary line), clicking the label toggles an inline panel
// that reveals the full summary. Replaces CC's terminal "ctrl+o" affordance,
// which never worked inside YouCoded because the chat view consumes that key.

interface Props {
marker: SystemMarkerData;
}

export default function SystemMarker({ marker }: Props) {
const [expanded, setExpanded] = useState(false);
const time = new Date(marker.timestamp).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const expandable = !!marker.summary;

return (
<div className="flex items-center gap-3 px-6 py-2 text-fg-muted">
<div className="flex-1 h-px bg-edge-dim" />
<span className="text-[11px] uppercase tracking-wider whitespace-nowrap">
{marker.label}
<span className="ml-2 text-fg-faint normal-case tracking-normal">· {time}</span>
</span>
<div className="flex-1 h-px bg-edge-dim" />
<div className="px-6 py-2 text-fg-muted">
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-edge-dim" />
{expandable ? (
// Render as a button so screen readers + keyboard navigation get
// the expand affordance for free.
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
className="text-[11px] uppercase tracking-wider whitespace-nowrap inline-flex items-center gap-1.5 hover:text-fg-2 transition-colors cursor-pointer"
>
<svg
width="8"
height="8"
viewBox="0 0 8 8"
aria-hidden="true"
className={`transition-transform ${expanded ? 'rotate-90' : ''}`}
>
<path d="M2 1 L6 4 L2 7" fill="none" stroke="currentColor" strokeWidth="1.2" />
</svg>
{marker.label}
<span className="ml-1 text-fg-faint normal-case tracking-normal">· {time}</span>
<span className="ml-1 text-fg-faint normal-case tracking-normal">
· {expanded ? 'hide' : 'view'} summary
</span>
</button>
) : (
<span className="text-[11px] uppercase tracking-wider whitespace-nowrap">
{marker.label}
<span className="ml-2 text-fg-faint normal-case tracking-normal">· {time}</span>
</span>
)}
<div className="flex-1 h-px bg-edge-dim" />
</div>
{expandable && expanded && (
// Inline panel — quiet styling, scrolls independently for long
// summaries. Pre-wrap preserves CC's numbered-list formatting;
// break-words so long paths/URLs wrap rather than overflow.
<div className="mt-2 mx-auto max-w-3xl rounded-md border border-edge-dim bg-inset/60 px-4 py-3">
<div className="text-[10px] uppercase tracking-wider text-fg-faint mb-2">
Compaction summary
</div>
<pre className="text-xs text-fg-2 whitespace-pre-wrap break-words font-sans leading-relaxed max-h-96 overflow-y-auto">
{marker.summary}
</pre>
</div>
)}
</div>
);
}
2 changes: 2 additions & 0 deletions desktop/src/renderer/components/buddy/BubbleFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ export function BubbleFeed({ sessionId }: Props) {
sessionId: event.sessionId,
markerId: `compact-done-${Date.now()}`,
afterContextTokens: null,
// Forward summary so buddy's marker matches main window's expandable behavior.
...(event.data.summary ? { summary: event.data.summary } : {}),
});
}
break;
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/renderer/state/chat-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -995,6 +995,10 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState {
timestamp: Date.now(),
label,
variant: 'compact',
// Attaches the CC-produced summary text so the otherwise-thin
// marker can click-to-expand inline. Absent on aborted/watchdog
// completions (no summary available).
...(action.summary ? { summary: action.summary } : {}),
},
},
],
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/renderer/state/chat-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ export interface SystemMarker {
timestamp: number;
label: string; // e.g. "Conversation cleared"
variant?: 'clear' | 'compact' | 'info'; // For styling hooks
// Optional long-form text the marker can reveal on click. Currently only
// set on compact markers — the actual conversation summary CC produced.
summary?: string;
}

// /copy [N] picker — shown inline when the Nth assistant turn has multiple
Expand Down Expand Up @@ -344,6 +347,7 @@ export type ChatAction =
markerId: string;
afterContextTokens: number | null;
aborted?: boolean; // true when watchdog fires — marker text differs
summary?: string; // Full compaction summary, surfaced as expandable section under the marker
}
// /copy picker for multi-block turns
| {
Expand Down
7 changes: 7 additions & 0 deletions desktop/src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ export interface TranscriptEvent {
* (plain) vs `[Request interrupted by user for tool use]` (tool-use).
*/
kind?: 'plain' | 'tool-use';
/**
* Populated on `compact-summary` events. The full text of the compaction
* summary CC wrote into the JSONL — pre-stripped of system tags. The
* reducer attaches it to the SystemMarker so the user can click-to-expand
* the otherwise-thin "Compacted" divider.
*/
summary?: string;
};
}

Expand Down
5 changes: 5 additions & 0 deletions docs/cc-dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ Update this table when you re-run snapshots after a CC version bump. Anything th
- **Depends on:** JSONL entries in `~/.claude/projects/<hash>/*.jsonl` with fields `type`, `message.role`, `message.content[]` (including `text`, `tool_use`, `tool_result`, `thinking` block shapes), `message.usage`, `requestId`, `stop_reason`, and per-turn heartbeats for extended-thinking models
- **Break symptom:** Transcript events stop dispatching; chat UI goes silent while CC still runs. Per-turn metadata (model, usage, requestId, stopReason) disappears from turn bubbles and attention banners.

### Post-/compact stdout echo + isCompactSummary line shape
- **Files:** `desktop/src/main/transcript-watcher.ts`, `app/src/main/kotlin/com/youcoded/app/parser/TranscriptWatcher.kt`
- **Depends on:** Two consecutive JSONL lines CC writes when `/compact` completes: (1) an `isCompactSummary: true` user-type line carrying the full conversation summary in `message.content`, and (2) an immediately-following user-type line whose `message.content` is `<local-command-stdout>Compacted (ctrl+o to see full summary)</local-command-stdout>` (CC's dimmed status echo of the local /compact command). The parser strips `<local-command-stdout>` / `<local-command-stderr>` ENTIRELY (not unwrap) — see the long comment on `STRIP_ENTIRELY_RE` in `transcript-watcher.ts`. Unwrapping let CC's dimmed echo reach the reducer's `TRANSCRIPT_USER_MESSAGE` "no pending match" path, which both (a) appended a fake user bubble reading "Compacted (ctrl+o to see full summary)" and (b) set `isThinking: true` with no transcript turn to ever clear it, leaving chat permanently stuck thinking after every `/compact`. The `isCompactSummary` line's `message.content` is what powers the click-to-expand summary inside the thin "Compacted · freed X tokens" marker; if CC stops emitting that line, the marker still appears (driven by COMPACTION_PENDING + the shrink backup path) but loses its expand affordance.
- **Break symptom:** If CC reintroduces a different stdout-echo wrapper tag, the fake bubble + stuck-thinking pair returns. If CC stops setting `isCompactSummary: true`, the COMPACTION_COMPLETE path falls back to the shrink-detection branch in App.tsx — works for typed `/compact` but loses summary text. If CC starts wrapping the summary in something other than plain text in `message.content`, the expandable panel shows the wrong thing or stays empty.

### Per-turn metadata fields
- **Files:** `desktop/src/renderer/state/chat-reducer.ts` (`TRANSCRIPT_TURN_COMPLETE`, `TRANSCRIPT_ASSISTANT_TEXT` handlers)
- **Depends on:** `message.usage.{input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens}`, outer `requestId` (Anthropic `req_…`), `stop_reason` values (`end_turn`, `max_tokens`, `refusal`, `stop_sequence`, `pause_turn`), Anthropic model ID in `message.model`
Expand Down
Loading