---
phase: 15-telegram-inline-buttons
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - plugins/claude-tts-companion/Sources/claude-tts-companion/InlineButtonManager.swift
  - plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift
autonomous: true
requirements: [BTN-01, BTN-02, BTN-03]

must_haves:
  truths:
    - "Arc Summary notification includes Focus Tab, Follow Up, and Transcript inline buttons"
    - "Pressing Focus Tab runs AppleScript to switch iTerm2 tab"
    - "New notification for same iTerm tab removes buttons from older message"
    - "Follow Up button sets pending session for resume"
    - "Transcript button sends paginated transcript view"
  artifacts:
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/InlineButtonManager.swift"
      provides: "Button state tracking: notifLookup (cwd, workspace, transcriptPath), focusTabMessages dedup map"
      exports: ["InlineButtonManager"]
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift"
      provides: "Callback query handling, inline keyboard attachment to Arc Summary, dedup via editMessageReplyMarkup"
  key_links:
    - from: "TelegramBot.swift"
      to: "InlineButtonManager.swift"
      via: "InlineButtonManager instance held by TelegramBot"
      pattern: "inlineButtonManager\\."
    - from: "TelegramBot.swift"
      to: "swift-telegram-sdk"
      via: "TGInlineKeyboardMarkup, TGCallbackQueryHandler, answerCallbackQuery, editMessageReplyMarkup"
      pattern: "TGInlineKeyboard|TGCallbackQuery|answerCallbackQuery|editMessageReplyMarkup"
---

<objective>
Add inline button infrastructure to the Telegram bot: button state manager, callback query handlers for Focus Tab / Follow Up / Transcript, inline keyboard construction, and Focus Tab deduplication.

Purpose: Port the legacy TypeScript inline button system (from notification-watcher.ts + commands.ts) to Swift, enabling interactive notification buttons.
Output: InlineButtonManager.swift (new), TelegramBot.swift (modified with callback handlers + keyboard attachment)
</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
@plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramFormatter.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift

<interfaces>
<!-- swift-telegram-sdk types the executor needs -->

From swift-telegram-sdk TGInlineKeyboardMarkup:

```swift
public final class TGInlineKeyboardMarkup: Codable, Sendable {
    public let inlineKeyboard: [[TGInlineKeyboardButton]]
    public init(inlineKeyboard: [[TGInlineKeyboardButton]])
}
```

From swift-telegram-sdk TGInlineKeyboardButton:

```swift
public final class TGInlineKeyboardButton: Codable, Sendable {
    public let text: String
    public let callbackData: String?
    // ... other optional fields
    public init(text: String, ..., callbackData: String? = nil, ...)
}
```

From swift-telegram-sdk TGReplyMarkup:

```swift
public enum TGReplyMarkup: Codable, Sendable {
    case inlineKeyboardMarkup(TGInlineKeyboardMarkup)
    case replyKeyboardMarkup(TGReplyKeyboardMarkup)
    case replyKeyboardRemove(TGReplyKeyboardRemove)
    case forceReply(TGForceReply)
    case undefined
}
```

From swift-telegram-sdk TGSendMessageParams (relevant field):

```swift
public init(..., replyMarkup: TGReplyMarkup? = nil)
```

From swift-telegram-sdk TGCallbackQueryHandler:

```swift
public final class TGCallbackQueryHandler: TGHandlerPrtcl {
    public init(name: String = ..., pattern: String, _ callback: @escaping TGHandlerCallbackAsync)
    // pattern is a regex matched against callbackQuery.data
}
```

From swift-telegram-sdk TGAnswerCallbackQueryParams:

```swift
public struct TGAnswerCallbackQueryParams: Encodable, Sendable {
    public init(callbackQueryId: String, text: String? = nil, showAlert: Bool? = nil, ...)
}
```

From swift-telegram-sdk TGEditMessageReplyMarkupParams:

```swift
public struct TGEditMessageReplyMarkupParams: Encodable, Sendable {
    public init(..., chatId: TGChatId? = nil, messageId: Int? = nil, ..., replyMarkup: TGInlineKeyboardMarkup? = nil)
}
```

From swift-telegram-sdk TGUpdate:

```swift
public let callbackQuery: TGCallbackQuery?
// TGUpdate.UpdateType includes: case callbackQuery = "callback_query"
```

From swift-telegram-sdk TGCallbackQuery:

```swift
public final class TGCallbackQuery: Codable, Sendable {
    public let id: String
    public let from: TGUser
    public let message: TGMaybeInaccessibleMessage?
    public let data: String?
}
```

Legacy TypeScript callback data format (port exactly):

- Focus Tab: `iterm:{uuid}` — uuid is the iTerm2 session ID
- Follow Up: `fu:{counter}` — counter is auto-incrementing Int
- Transcript: `tx:{counter}` — same counter as Follow Up (shared lookup)
- Transcript pagination: `txp:{chatId}:{counter}:{page}`

Legacy TypeScript dedup logic (port exactly):

- focusTabMessages: Map<itermSessionId, { chatId, messageId }>
- On new notification with itermSessionId: look up previous message, call editMessageReplyMarkup with nil markup to remove buttons
- Bounded to MAX_FOCUS_TAB_ENTRIES = 100 (FIFO eviction)

Legacy TypeScript notifLookup:

- notifLookup: Map<counter, { cwd, workspace, transcriptPath }>
- Bounded to 200 entries (FIFO eviction)
- Shared by Follow Up and Transcript buttons
  </interfaces>

</context>

<tasks>

<task type="auto">
  <name>Task 1: Create InlineButtonManager with state tracking and dedup</name>
  <files>plugins/claude-tts-companion/Sources/claude-tts-companion/InlineButtonManager.swift</files>
  <read_first>
    - ~/.claude/automation/claude-telegram-sync/src/claude-sync/notification-watcher.ts (lines 47-51 for focusTabMessages, lines 86-105 for notifLookup)
    - ~/.claude/automation/claude-telegram-sync/src/telegram/commands.ts (lines 86-107 for registerNotificationButtons and notifLookup)
  </read_first>
  <action>
Create InlineButtonManager.swift as a final class (not Sendable — accessed only from TelegramBot which is already @unchecked Sendable).

Port these data structures from the legacy TypeScript:

1. **notifLookup** — maps an auto-incrementing Int counter to `(cwd: String, workspace: String, transcriptPath: String)`. Used by both Follow Up and Transcript buttons. Bounded to 200 entries with FIFO eviction (when size > 200, remove the entry with the lowest key).

2. **focusTabMessages** — maps iTerm session ID (String UUID) to `(chatId: Int64, messageId: Int)`. Used for Focus Tab dedup: when a new notification arrives for the same iTerm tab, the previous message's buttons are removed. Bounded to 100 entries with FIFO eviction.

3. **registerNotification(cwd:workspace:transcriptPath:) -> Int** — increments counter, stores in notifLookup, evicts oldest if over 200, returns counter.

4. **lookupNotification(id:) -> (cwd: String, workspace: String, transcriptPath: String)?** — returns the entry or nil.

5. **trackFocusTab(itermSessionId:chatId:messageId:)** — stores in focusTabMessages, evicts oldest if over 100.

6. **previousFocusTabMessage(itermSessionId:) -> (chatId: Int64, messageId: Int)?** — returns the previous entry for dedup (before tracking the new one).

7. **buildInlineKeyboard(itermSessionId:notifId:) -> TGInlineKeyboardMarkup** — constructs a single-row keyboard with:
   - "Focus Tab" button with callbackData `iterm:{uuid}` (only if itermSessionId is non-nil)
   - "Follow Up" button with callbackData `fu:{notifId}`
   - "Transcript" button with callbackData `tx:{notifId}`
     Use emoji prefixes matching legacy: no emoji on Focus Tab text (just "Focus Tab"), no emoji on Follow Up/Transcript either — keep it clean for Swift. Actually check legacy: it uses "📺 Focus Tab", "💬 Follow Up", "📋 Transcript". Port those emoji exactly.

Import SwiftTelegramBot for TGInlineKeyboardMarkup and TGInlineKeyboardButton types.
</action>
<verify>
<automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>InlineButtonManager.swift compiles, provides registerNotification, lookupNotification, trackFocusTab, previousFocusTabMessage, buildInlineKeyboard methods</acceptance_criteria>
<done>InlineButtonManager exists with all state tracking and keyboard construction matching legacy TypeScript behavior</done>
</task>

<task type="auto">
  <name>Task 2: Add callback query handlers and inline keyboard to TelegramBot</name>
  <files>plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift</files>
  <read_first>
    - plugins/claude-tts-companion/Sources/claude-tts-companion/InlineButtonManager.swift (just created in Task 1)
    - ~/.claude/automation/claude-telegram-sync/src/telegram/commands.ts (lines 536-700 for callback handlers: iterm, fu, tx, txp)
    - plugins/claude-tts-companion/.build/checkouts/swift-telegram-sdk/Sources/SwiftTelegramBot/Bot/Handlers/CallbackQueryHandler.swift
  </read_first>
  <action>
Modify TelegramBot.swift with these changes:

**1. Add InlineButtonManager property:**

```swift
private let inlineButtonManager = InlineButtonManager()
```

**2. Change allowedUpdates to include callback_query:**
In `start()`, change:

```swift
connectionType: .longpolling(limit: 100, timeout: 30, allowedUpdates: [.message])
```

to:

```swift
connectionType: .longpolling(limit: 100, timeout: 30, allowedUpdates: [.message, .callbackQuery])
```

**3. Add sendMessageWithKeyboard method** that sends an HTML message with an inline keyboard and returns the message ID:

```swift
func sendMessageWithKeyboard(_ text: String, keyboard: TGInlineKeyboardMarkup, silent: Bool = false) async -> Int?
```

Uses TGSendMessageParams with `replyMarkup: .inlineKeyboardMarkup(keyboard)`, `disableNotification: silent`, `parseMode: .html`. Returns `msg.messageId` on success, nil on failure. On HTML parse error, retry as plain text WITHOUT keyboard (matching legacy fallback pattern).

**4. Add removeInlineKeyboard method** for Focus Tab dedup:

```swift
func removeInlineKeyboard(chatId: Int64, messageId: Int) async
```

Calls `bot.editMessageReplyMarkup(params: TGEditMessageReplyMarkupParams(chatId: .chat(chatId), messageId: messageId, replyMarkup: nil))`. Catch and ignore errors (message may be too old).

**5. Modify sendSessionNotification to accept itermSessionId and return the sent message ID:**
Add `itermSessionId: String?` parameter. After sending the Arc Summary message:

- Call `inlineButtonManager.registerNotification(cwd:workspace:transcriptPath:)` to get notifId. The workspace is derived from the cwd (last path component of cwd, or the basename).
- Call `inlineButtonManager.buildInlineKeyboard(itermSessionId:notifId:)` to get the keyboard.
- If itermSessionId is non-nil, call `inlineButtonManager.previousFocusTabMessage(itermSessionId:)` and if a previous entry exists, call `removeInlineKeyboard(chatId:messageId:)` to remove its buttons (dedup per BTN-03).
- Use `sendMessageWithKeyboard` instead of `sendMessage` for the Arc Summary message.
- After sending, if itermSessionId is non-nil, call `inlineButtonManager.trackFocusTab(itermSessionId:chatId:messageId:)`.

Note: The transcriptPath is not currently available in sendSessionNotification. Add it as a new parameter: `transcriptPath: String?`. If nil, skip button attachment.

**6. Register callback query handlers in BotDispatcher.handle():**

Add four TGCallbackQueryHandler registrations:

a) **Focus Tab** (`pattern: "^iterm:"`):

- Extract UUID from `update.callbackQuery?.data` by dropping "iterm:" prefix.
- Validate UUID format (8-36 hex chars with dashes).
- Run AppleScript via Process: `/usr/bin/osascript -e '...'` with the exact iTerm2 AppleScript from legacy:

  ```
  tell application "iTerm2"
    activate
    repeat with w in windows
      repeat with t in tabs of w
        repeat with s in sessions of t
          if unique ID of s ends with "{uuid}" then
            select t
            return "ok"
          end if
        end repeat
      end repeat
    end repeat
    return "not found"
  end tell
  ```

- Answer callback query with "Tab focused" if result is "ok", "Tab not found" otherwise.

b) **Follow Up** (`pattern: "^fu:"`):

- Extract counter from callback data.
- Look up in inlineButtonManager.lookupNotification(id:).
- If nil, answer "Button expired."
- Otherwise, answer "Following up" and send a reply message with the session CWD info, instructing user to type prompt.
- Note: Full session resume wiring (finding latest session, setting pending session) is complex and depends on session lister not yet ported. For now, send the informational message. The prompt executor already handles /prompt — user can manually /prompt after seeing the CWD.

c) **Transcript** (`pattern: "^tx:"`):

- Extract counter, look up notification.
- If nil, answer "Button expired."
- Parse transcript using TranscriptParser.parse(filePath:) and format as HTML overview.
- Use TelegramFormatter.chunkTelegramHtml to paginate if needed.
- For single-chunk: send as reply. For multi-chunk: send first chunk with pagination keyboard (txp: buttons).
- Store chunks in a transcript page store (simple dictionary with TTL, bounded to 50 entries).

d) **Transcript pagination** (`pattern: "^txp:"`):

- Parse tId and page from callback data.
- Look up stored chunks, edit message text with the requested page and updated keyboard.

For all callback handlers, use `try await bot.answerCallbackQuery(params: TGAnswerCallbackQueryParams(callbackQueryId: update.callbackQuery!.id, text: "..."))` to acknowledge.

**7. Add a formatTranscriptView helper** (private method or on TelegramFormatter):
Port from legacy `formatTranscriptView` in commands.ts. Uses TranscriptParser to parse the JSONL, then formats as numbered user prompts with tool/error counts. Keep it simple — show turn number, first 200 chars of each prompt, tool count, and last 500 chars of final response.

**8. Expose inlineButtonManager** via a public getter so main.swift can pass itermSessionId through to sendSessionNotification.
</action>
<verify>
<automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria> - TelegramBot compiles with callback query handlers registered - allowedUpdates includes .callbackQuery - sendSessionNotification accepts itermSessionId and transcriptPath parameters - Focus Tab handler runs AppleScript via Process - Dedup removes old keyboard via editMessageReplyMarkup
</acceptance_criteria>
<done>All three button types (Focus Tab, Follow Up, Transcript) have callback handlers; Arc Summary messages include inline keyboard; Focus Tab dedup removes buttons from older messages for same iTerm tab</done>
</task>

</tasks>

<verification>
1. `cd plugins/claude-tts-companion && swift build` compiles without errors
2. InlineButtonManager tracks notifications with bounded FIFO maps
3. TelegramBot registers callback handlers for iterm:, fu:, tx:, txp: patterns
4. sendSessionNotification attaches inline keyboard to Arc Summary
5. Focus Tab dedup calls editMessageReplyMarkup to remove old buttons
</verification>

<success_criteria>

- swift build succeeds
- All three button types (BTN-01) have callback handlers
- Focus Tab uses AppleScript via Process (BTN-02)
- Focus Tab dedup removes old buttons (BTN-03)
- Callback data format matches legacy: iterm:{uuid}, fu:{id}, tx:{id}
  </success_criteria>

<output>
After completion, create `.planning/phases/15-telegram-inline-buttons/15-01-SUMMARY.md`
</output>
