---
phase: 17-tts-streaming-subtitle-chunking
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleChunker.swift
  - plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitlePanel.swift
  - plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleStyle.swift
autonomous: true
requirements: [STREAM-01, STREAM-02, STREAM-03]

must_haves:
  truths:
    - "Subtitle text is split into 2-line pages using pixel-width measurement"
    - "Each page contains words with start index into the full word array for timing slicing"
    - "SubtitlePanel shows pages sequentially with karaoke highlighting per page"
    - "Page flip is instant when karaoke reaches last word of current page"
    - "Generation counter prevents stale work items from firing after interruption"
    - "showUtterance() still works as a single-page wrapper for backward compatibility"
  artifacts:
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleChunker.swift"
      provides: "Text-to-pages chunking algorithm"
      contains: "struct SubtitlePage"
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitlePanel.swift"
      provides: "Paged karaoke display"
      contains: "func showPages"
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleStyle.swift"
      provides: "truncatesLastVisibleLine constant"
      contains: "truncatesLastVisibleLine"
  key_links:
    - from: "SubtitleChunker.swift"
      to: "SubtitleStyle.swift"
      via: "SubtitleStyle.regularFont and SubtitleStyle.widthRatio for width measurement"
      pattern: "SubtitleStyle\\.regularFont"
    - from: "SubtitlePanel.swift"
      to: "SubtitleChunker.swift"
      via: "SubtitlePage struct consumed by showPages()"
      pattern: "SubtitlePage"
---

<objective>
Create SubtitleChunker and refactor SubtitlePanel for paged karaoke display.

Purpose: Transform subtitle display from a single flat word list to paginated 2-line pages that flip as karaoke highlighting advances. This is the core display logic that STREAM-02 and STREAM-03 depend on.
Output: SubtitleChunker.swift (new), SubtitlePanel.swift (refactored with showPages), SubtitleStyle.swift (truncatesLastVisibleLine added)
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/17-tts-streaming-subtitle-chunking/17-CONTEXT.md
@.planning/phases/17-tts-streaming-subtitle-chunking/17-RESEARCH.md

@plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitlePanel.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleStyle.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/TTSEngine.swift

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->

From SubtitleStyle.swift:

```swift
@MainActor
enum SubtitleStyle {
    static let currentWordColor = NSColor(red: 1.0, green: 0.843, blue: 0.0, alpha: 1.0)
    static let pastWordColor = NSColor(red: 0.627, green: 0.627, blue: 0.627, alpha: 1.0)
    static let futureWordColor = NSColor.white
    static let currentWordFont = font(size: FontSize.medium.rawValue, weight: .bold)
    static let regularFont = font(size: FontSize.medium.rawValue, weight: .regular)
    static let horizontalPadding: CGFloat = 16
    static let verticalPadding: CGFloat = 12
    static let widthRatio: CGFloat = 0.7
    static let maxLines = 2
    static let lingerDuration: TimeInterval = 2.0
    static func font(size: CGFloat, weight: NSFont.Weight) -> NSFont
}
```

From SubtitlePanel.swift:

```swift
@MainActor
final class SubtitlePanel: NSPanel {
    func show(text: String)
    func hide()
    func updateAttributedText(_ text: NSAttributedString)
    func highlightWord(at index: Int, in words: [String])
    func showUtterance(_ text: String, wordTimings: [TimeInterval])
    func demo()
    func positionOnScreen()
    private func cancelScheduledHighlights()
}
```

From TTSEngine.swift:

```swift
struct TTSResult {
    let wavPath: String
    let text: String
    let wordTimings: [TimeInterval]  // per-word DURATION (not onset)
    let audioDuration: TimeInterval
}
```

</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create SubtitleChunker with SubtitlePage model and width-based line breaking</name>
  <files>
    plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleChunker.swift
    plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleStyle.swift
  </files>
  <read_first>
    plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleStyle.swift
    plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitlePanel.swift
  </read_first>
  <action>
Create a new file `SubtitleChunker.swift` with:

1. **SubtitlePage struct** (top-level, not nested):

```swift
struct SubtitlePage {
    let words: [String]
    let startWordIndex: Int
    var wordCount: Int { words.count }
}
```

1. **SubtitleChunker enum** marked `@MainActor` (because NSAttributedString.size() requires main thread):

Static methods:

- `static func chunkIntoPages(text: String) -> [SubtitlePage]` -- main entry point. Splits text by spaces into words, calls `availableLineWidth()`, then runs greedy page fill. Filters out empty pages before returning.

- `static func availableLineWidth() -> CGFloat` -- computes available line width as: `(NSScreen.main?.visibleFrame.width ?? 2056) * SubtitleStyle.widthRatio - (SubtitleStyle.horizontalPadding * 2)`. This yields ~1407pt on 2056pt screen.

- `static func measureWidth(_ text: String) -> CGFloat` -- creates `NSAttributedString(string: text, attributes: [.font: SubtitleStyle.regularFont])` and returns `ceil(attrStr.size().width)`. Measures the unbounded rendered width.

- `static func breakPriority(_ word: String) -> Int` -- returns 3 for clause boundary (word ends with `,`, `;`, `:`, or `\u{2014}` em-dash), 2 for phrase boundary (word lowercased is in `["and", "or", "but", "for", "nor", "yet", "so", "in", "on", "at", "to", "of", "by", "with", "from"]`), 1 for any other word.

- `private static func fillLine(words: [String], from startIndex: Int, maxWidth: CGFloat, preferShorter: Bool) -> (words: [String], nextIndex: Int)` -- greedy fill algorithm:
  - Walk from startIndex, accumulating words. After each word, measure the full line text so far (words joined by " ") with `measureWidth()`.
  - Track `lastBreakIndex` and `lastBreakPriority` -- when a word has breakPriority >= 2, record it.
  - When accumulated width exceeds maxWidth:
    - If `preferShorter` is true (line 1, for bottom-heavy shape) AND a clause/phrase break exists in the last 30% of words added, backtrack to that break point.
    - Otherwise backtrack to the word before overflow.
  - Return the words for this line and the next index to continue from.
  - Edge case: if the very first word overflows, include it anyway (single long word).

- `private static func chunkIntoPages(words: [String], availableWidth: CGFloat) -> [SubtitlePage]` -- the core loop:
  - While wordIndex < words.count:
    - Call `fillLine(words:from:maxWidth:preferShorter: true)` for line 1
    - Call `fillLine(words:from:maxWidth:preferShorter: false)` for line 2
    - Combine line1Words + line2Words into a SubtitlePage with startWordIndex = current wordIndex
    - Advance wordIndex to line2's nextIndex
  - Filter out pages with empty words arrays before returning.

1. **SubtitleStyle.swift modification**: Add one constant:

```swift
/// Safety fallback: truncate text that overflows the 2-line display
static let truncatesLastVisibleLine = true
```

  </action>
  <verify>
    <automated>cd plugins/claude-tts-companion && swift build -c debug 2>&1 | tail -5</automated>
  </verify>
  <acceptance_criteria>
    - SubtitleChunker.swift exists and contains `struct SubtitlePage`
    - SubtitleChunker.swift contains `@MainActor` and `enum SubtitleChunker`
    - SubtitleChunker.swift contains `func chunkIntoPages(text: String) -> [SubtitlePage]`
    - SubtitleChunker.swift contains `func measureWidth(_ text: String) -> CGFloat`
    - SubtitleChunker.swift contains `func breakPriority(_ word: String) -> Int`
    - SubtitleChunker.swift contains `func availableLineWidth() -> CGFloat`
    - SubtitleChunker.swift contains `SubtitleStyle.regularFont` (uses correct font for measurement)
    - SubtitleChunker.swift contains `SubtitleStyle.widthRatio` (uses panel width ratio)
    - SubtitleChunker.swift contains `SubtitleStyle.horizontalPadding` (accounts for padding)
    - SubtitleChunker.swift contains `\u{2014}` (em-dash clause boundary detection)
    - SubtitleChunker.swift contains `preferShorter` parameter (bottom-heavy line shape)
    - SubtitleStyle.swift contains `static let truncatesLastVisibleLine = true`
    - `swift build -c debug` completes with exit code 0
  </acceptance_criteria>
  <done>SubtitleChunker splits text into 2-line SubtitlePage arrays using pixel-width measurement with clause-priority line breaking and bottom-heavy preference. SubtitleStyle has truncatesLastVisibleLine constant. Project compiles.</done>
</task>

<task type="auto">
  <name>Task 2: Refactor SubtitlePanel with showPages(), generation counter, and backward-compatible showUtterance()</name>
  <files>
    plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitlePanel.swift
  </files>
  <read_first>
    plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitlePanel.swift
    plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleChunker.swift
    plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleStyle.swift
  </read_first>
  <action>
Refactor SubtitlePanel.swift to support paged karaoke display:

1. **Add generation counter** for interruption safety:

```swift
/// Generation counter to invalidate stale work items on interruption.
private var generation: Int = 0
```

1. **Add new `showPages(_:wordTimings:)` method** as the primary API:

```swift
/// Display multiple pages of subtitle text with karaoke highlighting.
/// Audio plays continuously as a single WAV; pages flip when karaoke
/// reaches the last word of each page.
func showPages(_ pages: [SubtitlePage], wordTimings: [TimeInterval]) {
    cancelScheduledHighlights()
    guard !pages.isEmpty else { return }

    generation += 1
    let myGeneration = generation

    var cumulativeTime: TimeInterval = 0

    for (pageIndex, page) in pages.enumerated() {
        let pageStartTime = cumulativeTime
        let pageWords = page.words

        // Schedule page display: show all words in future (white) color
        let showPageItem = DispatchWorkItem { [weak self] in
            guard let self = self, self.generation == myGeneration else { return }
            self.highlightWord(at: -1, in: pageWords)
        }
        scheduledWorkItems.append(showPageItem)
        DispatchQueue.main.asyncAfter(deadline: .now() + pageStartTime, execute: showPageItem)

        // Schedule per-word karaoke highlights within this page
        for localIndex in 0..<page.wordCount {
            let globalIndex = page.startWordIndex + localIndex
            let timing = globalIndex < wordTimings.count ? wordTimings[globalIndex] : 0.2
            let fireTime = cumulativeTime

            let capturedLocalIndex = localIndex
            let item = DispatchWorkItem { [weak self] in
                guard let self = self, self.generation == myGeneration else { return }
                self.highlightWord(at: capturedLocalIndex, in: pageWords)
            }
            scheduledWorkItems.append(item)
            DispatchQueue.main.asyncAfter(deadline: .now() + fireTime, execute: item)
            cumulativeTime += timing
        }
    }

    // Linger on last page then hide
    let lingerItem = DispatchWorkItem { [weak self] in
        guard let self = self, self.generation == myGeneration else { return }
        self.hide()
    }
    self.lingerWorkItem = lingerItem
    scheduledWorkItems.append(lingerItem)
    DispatchQueue.main.asyncAfter(
        deadline: .now() + cumulativeTime + SubtitleStyle.lingerDuration,
        execute: lingerItem
    )
}
```

1. **Refactor existing `showUtterance()` to wrap `showPages()`** for backward compatibility:
   Replace the current showUtterance implementation with:

```swift
/// Display an utterance with word-level karaoke highlighting driven by timing data.
///
/// Wraps showPages() with a single page for backward compatibility.
/// Used by demo() and simple text display.
func showUtterance(_ text: String, wordTimings: [TimeInterval]) {
    let words = text.split(separator: " ").map(String.init)
    let singlePage = SubtitlePage(words: words, startWordIndex: 0)
    showPages([singlePage], wordTimings: wordTimings)
}
```

1. **Update `highlightWord(at:in:)`**: The existing implementation already handles index -1 gracefully (all words will be > index, so all get futureWordColor). No change needed to the logic, but verify it handles index == -1 correctly -- when index is -1, no word matches `i == index` and no word matches `i < index`, so all words get futureWordColor (white). This is correct.

2. **Apply truncatesLastVisibleLine to textField** in `configureContentView()`:
   After the existing textField configuration, add:

```swift
field.cell?.truncatesLastVisibleLine = SubtitleStyle.truncatesLastVisibleLine
```

Add this line inside the `textField` lazy var closure, after `field.cell?.isScrollable = false`.

1. **Update cancelScheduledHighlights()**: Also increment generation to invalidate any in-flight items:

```swift
private func cancelScheduledHighlights() {
    generation += 1
    for item in scheduledWorkItems {
        item.cancel()
    }
    scheduledWorkItems.removeAll()
    lingerWorkItem?.cancel()
    lingerWorkItem = nil
}
```

  </action>
  <verify>
    <automated>cd plugins/claude-tts-companion && swift build -c debug 2>&1 | tail -5</automated>
  </verify>
  <acceptance_criteria>
    - SubtitlePanel.swift contains `private var generation: Int = 0`
    - SubtitlePanel.swift contains `func showPages(_ pages: [SubtitlePage], wordTimings: [TimeInterval])`
    - SubtitlePanel.swift contains `self.generation == myGeneration` (generation guard in work items)
    - SubtitlePanel.swift contains `SubtitlePage(words: words, startWordIndex: 0)` (showUtterance wrapper)
    - SubtitlePanel.swift `showUtterance` calls `showPages` (not its own scheduling loop)
    - SubtitlePanel.swift contains `truncatesLastVisibleLine` in textField setup
    - SubtitlePanel.swift `cancelScheduledHighlights()` increments generation
    - SubtitlePanel.swift `demo()` method still exists (unchanged, calls showUtterance which delegates to showPages)
    - `swift build -c debug` completes with exit code 0
  </acceptance_criteria>
  <done>SubtitlePanel shows pages sequentially with per-page karaoke highlighting and instant page flips. Generation counter prevents stale work items. showUtterance() wraps showPages() for backward compatibility. Panel uses truncatesLastVisibleLine as safety fallback. Project compiles.</done>
</task>

</tasks>

<verification>
- `swift build -c debug` succeeds (zero errors)
- SubtitleChunker.swift exists with SubtitlePage model and chunkIntoPages() entry point
- SubtitlePanel.swift has showPages() method that schedules page-flip + per-word karaoke
- SubtitlePanel.swift showUtterance() delegates to showPages() (backward compat preserved)
- Generation counter in SubtitlePanel prevents stale work item execution after interruption
- SubtitleStyle.swift has truncatesLastVisibleLine constant
</verification>

<success_criteria>

- Project compiles with `swift build -c debug`
- SubtitleChunker can split arbitrary text into 2-line pages using pixel-width measurement
- SubtitlePanel.showPages() schedules page flips and per-word karaoke from timing arrays
- showUtterance() still works for demo() and simple single-page text
- Interruption safety via generation counter is in place
  </success_criteria>

<output>
After completion, create `.planning/phases/17-tts-streaming-subtitle-chunking/17-01-SUMMARY.md`
</output>
