{"version":3,"file":"team-attach-panel.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/team-attach-panel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,0BAA0B,CAAC;AAG1E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AACpE,OAAO,KAAK,EAAE,kBAAkB,EAAiB,MAAM,4BAA4B,CAAC;AAKpF,+EAA+E;AAC/E,qBAAa,UAAU,CAAC,CAAC;IAKZ,QAAQ,CAAC,QAAQ,EAAE,MAAM;IAJrC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAoB;IAC1C,OAAO,CAAC,KAAK,CAAK;IAClB,OAAO,CAAC,KAAK,CAAK;IAElB,YAAqB,QAAQ,EAAE,MAAM,EAGpC;IAED,IAAI,CAAC,IAAI,EAAE,CAAC,GAAG,IAAI,CAKlB;IAED,IAAI,MAAM,IAAI,MAAM,CAEnB;IAED,OAAO,IAAI,CAAC,EAAE,CAMb;CACD;AAOD,MAAM,WAAW,wBAAwB;IACxC,wDAAwD;IACxD,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,oCAAoC;IACpC,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAChC;AAKD,qBAAa,wBAAyB,YAAW,SAAS,EAAE,SAAS;IAYnE,QAAQ,CAAC,IAAI,EAAE,MAAM;IAErB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC;IAdrB,OAAO,UAAS;IAEhB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqB;IAC3C,gFAAgF;IAChF,OAAO,CAAC,OAAO,CAAM;IACrB,OAAO,CAAC,WAAW,CAA+B;IAClD,OAAO,CAAC,WAAW,CAA2B;IAC9C,sEAAsE;IACtE,OAAO,CAAC,QAAQ,CAA+F;IAE/G,YACU,IAAI,EAAE,MAAM,EACrB,UAAU,EAAE,kBAAkB,EACb,SAAS,EAAE,wBAAwB,EACnC,EAAE,CAAC,iBAAK,EACzB,WAAW,SAAuB,EASlC;IAED,yFAAyF;IACzF,OAAO,IAAI,IAAI,CAId;IAED,UAAU,IAAI,IAAI,CAEjB;IAED;;;;;;OAMG;IACH,eAAe,CAAC,QAAQ,EAAE,YAAY,EAAE,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA6CxF;IAED,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAc9B;IAED,OAAO,CAAC,aAAa;IAIrB,mEAAmE;IACnE,OAAO,CAAC,SAAS;IAMjB,iFAAiF;IACjF,OAAO,CAAC,WAAW;IAYnB,4EAA4E;IAC5E,OAAO,CAAC,UAAU;IA0FlB,6EAA6E;IAC7E,iBAAiB,IAAI,MAAM,CAE1B;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAqE9B;CACD","sourcesContent":["/**\n * Attach side-panel for a hooteams role (`a` in team focus).\n *\n * Renders one role's live TeamEvents in the style of hooteams' StreamRenderer\n * (`hooteams attach <role>`): ◉ lifecycle lines, dim italic thinking, inline\n * streaming text, ✓/✗ tool results, dim per-turn usage stamps. Differences\n * from the CLI renderer are dictated by the host: colors go through hoocode's\n * theme helpers (no raw ANSI) and output lands in a bounded ring buffer\n * instead of an unbounded stdout stream.\n *\n * The panel filters the team connection's single shared /events subscription —\n * it never opens its own SSE connection — and unsubscribes on dispose(), so\n * attach/detach cycles leave no leaked subscribers.\n *\n * Approval gates: task_* lifecycle events render as stream lines, and when the\n * attached role pauses, presentApproval() embeds the AskOptions pane right\n * where the stream stopped — pick an option (or type a free-form answer) and\n * the caller answers the server; the stream then carries on under a\n * \"✓ answered: …\" stamp.\n */\n\nimport type { Component, Focusable, TUI } from \"@kolisachint/hoocode-tui\";\nimport { getKeybindings, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from \"@kolisachint/hoocode-tui\";\nimport type { AskQuestion } from \"../../../core/extensions/types.js\";\nimport type { TeamApproval } from \"../../../core/team-approvals.js\";\nimport type { TeamViewConnection, TeamViewEvent } from \"../../../core/team-view.js\";\nimport { theme } from \"../theme/theme.js\";\nimport { AskOptionsComponent } from \"./ask-options.js\";\nimport { appKeyLabel, matchesAppKey, rawKeyHint } from \"./keybinding-hints.js\";\n\n/** Fixed-capacity FIFO over a circular array: push evicts the oldest entry. */\nexport class RingBuffer<T> {\n\tprivate readonly slots: (T | undefined)[];\n\tprivate start = 0;\n\tprivate count = 0;\n\n\tconstructor(readonly capacity: number) {\n\t\tif (!Number.isInteger(capacity) || capacity <= 0) throw new Error(`invalid ring buffer capacity ${capacity}`);\n\t\tthis.slots = new Array<T | undefined>(capacity);\n\t}\n\n\tpush(item: T): void {\n\t\tconst end = (this.start + this.count) % this.capacity;\n\t\tthis.slots[end] = item;\n\t\tif (this.count < this.capacity) this.count++;\n\t\telse this.start = (this.start + 1) % this.capacity;\n\t}\n\n\tget length(): number {\n\t\treturn this.count;\n\t}\n\n\ttoArray(): T[] {\n\t\tconst out: T[] = [];\n\t\tfor (let i = 0; i < this.count; i++) {\n\t\t\tout.push(this.slots[(this.start + i) % this.capacity] as T);\n\t\t}\n\t\treturn out;\n\t}\n}\n\nfunction argsPreview(args: unknown, max = 60): string {\n\tconst text = JSON.stringify(args) ?? \"\";\n\treturn text.length > max ? `${text.slice(0, max)}…` : text;\n}\n\nexport interface TeamAttachPanelCallbacks {\n\t/** `q`/esc: close the panel; the role keeps running. */\n\tonDetach: () => void;\n\t/** `n`: nudge the attached role. */\n\tonNudge: (role: string) => void;\n}\n\n/** Logical event lines kept in the buffer (wrapped to width at render time). */\nconst DEFAULT_BUFFER_LINES = 200;\n\nexport class TeamAttachPanelComponent implements Component, Focusable {\n\tfocused = false;\n\n\tprivate readonly lines: RingBuffer<string>;\n\t/** Streaming tail (text/thinking deltas) not yet terminated by a line break. */\n\tprivate partial = \"\";\n\tprivate partialKind: \"text\" | \"thinking\" = \"text\";\n\tprivate unsubscribe: (() => void) | undefined;\n\t/** Gate currently embedded in the panel; input is delegated to it. */\n\tprivate approval: { component: AskOptionsComponent; settle: (answer: string | undefined) => void } | undefined;\n\n\tconstructor(\n\t\treadonly role: string,\n\t\tconnection: TeamViewConnection,\n\t\tprivate readonly callbacks: TeamAttachPanelCallbacks,\n\t\tprivate readonly ui?: TUI,\n\t\tbufferLines = DEFAULT_BUFFER_LINES,\n\t) {\n\t\tthis.lines = new RingBuffer(bufferLines);\n\t\t// Filter the shared stream down to this role; no second SSE connection.\n\t\tthis.unsubscribe = connection.subscribe((event) => {\n\t\t\tif (event.role !== this.role) return;\n\t\t\tthis.applyEvent(event);\n\t\t\tthis.ui?.requestRender();\n\t\t});\n\t}\n\n\t/** Detach from the shared event stream; settles any open gate as skipped. Idempotent. */\n\tdispose(): void {\n\t\tthis.unsubscribe?.();\n\t\tthis.unsubscribe = undefined;\n\t\tthis.approval?.settle(undefined);\n\t}\n\n\tinvalidate(): void {\n\t\t// No cached rendering state.\n\t}\n\n\t/**\n\t * Embed one approval gate in the panel (the attached role paused). Resolves\n\t * with the chosen or free-form answer; undefined when skipped (esc), when\n\t * the signal aborts (answered elsewhere), or when the panel is disposed.\n\t * Answering the server is the caller's job — on an answer the panel just\n\t * stamps \"✓ answered: …\" into the stream.\n\t */\n\tpresentApproval(approval: TeamApproval, signal: AbortSignal): Promise<string | undefined> {\n\t\treturn new Promise((resolve) => {\n\t\t\tif (signal.aborted || this.unsubscribe === undefined) {\n\t\t\t\tresolve(undefined);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// The coordinator shows one gate at a time; a stray second call\n\t\t\t// settles the first as skipped instead of stacking panes.\n\t\t\tthis.approval?.settle(undefined);\n\n\t\t\tlet settled = false;\n\t\t\tconst settle = (answer: string | undefined): void => {\n\t\t\t\tif (settled) return;\n\t\t\t\tsettled = true;\n\t\t\t\tsignal.removeEventListener(\"abort\", onAbort);\n\t\t\t\tthis.approval = undefined;\n\t\t\t\tif (answer !== undefined) {\n\t\t\t\t\tthis.breakLine();\n\t\t\t\t\tthis.lines.push(theme.fg(\"success\", `  ✓ answered: ${answer}`));\n\t\t\t\t}\n\t\t\t\tthis.ui?.requestRender();\n\t\t\t\tresolve(answer);\n\t\t\t};\n\t\t\tconst onAbort = (): void => settle(undefined);\n\t\t\tsignal.addEventListener(\"abort\", onAbort, { once: true });\n\n\t\t\tconst question: AskQuestion = {\n\t\t\t\tquestion: approval.question,\n\t\t\t\tshort: approval.taskId,\n\t\t\t\tdetail: `team task \"${approval.taskId}\" is paused until answered`,\n\t\t\t\toptions: approval.options.map((label) => ({ label })),\n\t\t\t\tallowCustom: true,\n\t\t\t};\n\t\t\t// This panel is already a framed surface with its own header and rule,\n\t\t\t// so the gate inside it draws no frame of its own.\n\t\t\tconst component = new AskOptionsComponent(\n\t\t\t\t[question],\n\t\t\t\t(answers) => settle(answers[0]),\n\t\t\t\t() => settle(undefined),\n\t\t\t\t{ framed: false },\n\t\t\t);\n\t\t\tcomponent.focused = this.focused;\n\t\t\tthis.approval = { component, settle };\n\t\t\tthis.ui?.requestRender();\n\t\t});\n\t}\n\n\thandleInput(data: string): void {\n\t\t// An open gate owns the keyboard: q/n must type into the custom row, not\n\t\t// detach or nudge. esc skips the gate (AskOptions cancel), not the panel.\n\t\tif (this.approval) {\n\t\t\tthis.approval.component.handleInput(data);\n\t\t\treturn;\n\t\t}\n\t\tif (matchesKey(data, \"q\") || getKeybindings().matches(data, \"tui.select.cancel\")) {\n\t\t\tthis.callbacks.onDetach();\n\t\t\treturn;\n\t\t}\n\t\tif (matchesAppKey(data, \"app.team.nudge\")) {\n\t\t\tthis.callbacks.onNudge(this.role);\n\t\t}\n\t}\n\n\tprivate styledPartial(): string {\n\t\treturn this.partialKind === \"thinking\" ? theme.italic(theme.fg(\"dim\", this.partial)) : this.partial;\n\t}\n\n\t/** Flush the streaming tail into the buffer as a finished line. */\n\tprivate breakLine(): void {\n\t\tif (this.partial.length === 0) return;\n\t\tthis.lines.push(this.styledPartial());\n\t\tthis.partial = \"\";\n\t}\n\n\t/** Append streaming delta text, completing buffer lines at embedded newlines. */\n\tprivate appendDelta(delta: string, kind: \"text\" | \"thinking\"): void {\n\t\tif (this.partialKind !== kind) this.breakLine();\n\t\tthis.partialKind = kind;\n\t\tconst parts = delta.split(\"\\n\");\n\t\tfor (let i = 0; i < parts.length - 1; i++) {\n\t\t\tthis.partial += parts[i];\n\t\t\tthis.breakLine();\n\t\t\tthis.partialKind = kind;\n\t\t}\n\t\tthis.partial += parts[parts.length - 1];\n\t}\n\n\t/** Mirrors hooteams' StreamRenderer event handling, themed and buffered. */\n\tprivate applyEvent(event: TeamViewEvent): void {\n\t\tswitch (event.type) {\n\t\t\tcase \"agent_start\":\n\t\t\t\tthis.breakLine();\n\t\t\t\tthis.lines.push(theme.fg(\"accent\", `◉ ${event.role} started`));\n\t\t\t\tbreak;\n\t\t\tcase \"message_update\": {\n\t\t\t\tconst delta = event.assistantMessageEvent;\n\t\t\t\tif (!delta) break;\n\t\t\t\tif (delta.type === \"thinking_start\") {\n\t\t\t\t\tthis.breakLine();\n\t\t\t\t\tthis.lines.push(theme.fg(\"dim\", \"◉ thinking…\"));\n\t\t\t\t} else if (delta.type === \"thinking_delta\") {\n\t\t\t\t\tthis.appendDelta(delta.delta ?? \"\", \"thinking\");\n\t\t\t\t} else if (delta.type === \"thinking_end\") {\n\t\t\t\t\tthis.breakLine();\n\t\t\t\t} else if (delta.type === \"text_delta\") {\n\t\t\t\t\tthis.appendDelta(delta.delta ?? \"\", \"text\");\n\t\t\t\t} else if (delta.type === \"text_end\") {\n\t\t\t\t\tthis.breakLine();\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"tool_execution_start\":\n\t\t\t\tthis.breakLine();\n\t\t\t\tthis.lines.push(\n\t\t\t\t\ttheme.fg(\"accent\", \"◉ tool: \") +\n\t\t\t\t\t\ttheme.bold(theme.fg(\"accent\", event.toolName ?? \"?\")) +\n\t\t\t\t\t\ttheme.fg(\"accent\", `(${argsPreview(event.args)})`) +\n\t\t\t\t\t\t\" \" +\n\t\t\t\t\t\ttheme.fg(\"warning\", \"running…\"),\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\tcase \"tool_execution_end\":\n\t\t\t\tthis.breakLine();\n\t\t\t\tthis.lines.push(\n\t\t\t\t\tevent.isError\n\t\t\t\t\t\t? theme.fg(\"error\", `  ✗ ${event.toolName ?? \"?\"} failed`)\n\t\t\t\t\t\t: theme.fg(\"success\", `  ✓ ${event.toolName ?? \"?\"} done`),\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\tcase \"turn_end\": {\n\t\t\t\tthis.breakLine();\n\t\t\t\tconst usage = event.message?.usage;\n\t\t\t\tif (usage) {\n\t\t\t\t\tconst cost = usage.cost?.total ? ` $${usage.cost.total.toFixed(4)}` : \"\";\n\t\t\t\t\tthis.lines.push(\n\t\t\t\t\t\ttheme.fg(\"dim\", `— turn: ${usage.input ?? 0} in / ${usage.output ?? 0} out tokens${cost}`),\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tif (event.message?.errorMessage) {\n\t\t\t\t\tthis.lines.push(theme.fg(\"error\", `error: ${event.message.errorMessage}`));\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"agent_end\":\n\t\t\t\tthis.breakLine();\n\t\t\t\tthis.lines.push(theme.fg(\"accent\", `◉ ${event.role} idle`));\n\t\t\t\tbreak;\n\t\t\tcase \"task_started\":\n\t\t\t\tthis.breakLine();\n\t\t\t\tthis.lines.push(theme.fg(\"accent\", `◉ task ${event.taskId ?? \"?\"} started`));\n\t\t\t\tbreak;\n\t\t\tcase \"task_paused\":\n\t\t\t\tthis.breakLine();\n\t\t\t\t// VS15 (U+FE0E) forces text presentation: bare ⏸/▶ carry the Unicode\n\t\t\t\t// Emoji property and emoji-font fallback renders them double-width,\n\t\t\t\t// breaking the width math that counts one cell.\n\t\t\t\tthis.lines.push(theme.fg(\"warning\", `⏸︎ awaiting approval: ${event.question ?? \"?\"}`));\n\t\t\t\tbreak;\n\t\t\tcase \"task_resumed\":\n\t\t\t\tthis.breakLine();\n\t\t\t\tthis.lines.push(\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"accent\",\n\t\t\t\t\t\t`▶︎ task ${event.taskId ?? \"?\"} resumed${event.chosenOption ? `: ${event.chosenOption}` : \"\"}`,\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\tcase \"task_finished\":\n\t\t\t\tthis.breakLine();\n\t\t\t\tthis.lines.push(\n\t\t\t\t\tevent.status === \"error\"\n\t\t\t\t\t\t? theme.fg(\"error\", `✗ task ${event.taskId ?? \"?\"} failed`)\n\t\t\t\t\t\t: theme.fg(\"success\", `✓ task ${event.taskId ?? \"?\"} done`),\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t}\n\t}\n\n\t/** Buffered logical line count (completed lines only). Exposed for tests. */\n\tbufferedLineCount(): number {\n\t\treturn this.lines.length;\n\t}\n\n\trender(width: number): string[] {\n\t\tconst inner = Math.max(1, width);\n\n\t\t// Header: role identity left, key hints right. An open gate owns the\n\t\t// keyboard and renders its own hints, so the panel's would lie.\n\t\tconst titlePlain = `◉ ${this.role} — attached`;\n\t\tconst title =\n\t\t\ttheme.fg(\"accent\", \"◉ \") + theme.bold(theme.fg(\"accent\", this.role)) + theme.fg(\"muted\", \" — attached\");\n\t\t// House hint style: dim key + muted description, muted · separator. The\n\t\t// nudge key resolves from the live keybinding config (same binding the\n\t\t// task panel's team focus uses); q stays a literal by convention.\n\t\tconst nudgeKey = appKeyLabel(\"app.team.nudge\");\n\t\tconst hintsPlain = this.approval ? \"\" : `${nudgeKey} nudge · q detach`;\n\t\tconst hints = this.approval\n\t\t\t? \"\"\n\t\t\t: rawKeyHint(nudgeKey, \"nudge\") + theme.fg(\"muted\", \" · \") + rawKeyHint(\"q\", \"detach\");\n\t\tlet header: string;\n\t\tif (visibleWidth(titlePlain) + 2 + visibleWidth(hintsPlain) <= inner) {\n\t\t\theader = title + \" \".repeat(inner - visibleWidth(titlePlain) - visibleWidth(hintsPlain)) + hints;\n\t\t} else {\n\t\t\theader = truncateToWidth(title, inner, \"…\");\n\t\t}\n\t\tconst rule = theme.fg(\"borderMuted\", \"─\".repeat(inner));\n\n\t\t// Body: wrap each buffered line, then keep the newest rows that fit the\n\t\t// panel's height budget (the freshest output hugs the bottom, like a\n\t\t// terminal tail). Budget derives from the live terminal height so the\n\t\t// panel never asks the overlay compositor to clip it (clipping drops the\n\t\t// bottom — exactly the rows we care about).\n\t\tconst rows = this.ui?.terminal.rows ?? 24;\n\t\tconst bodyBudget = Math.max(5, Math.floor(rows * 0.6) - 3);\n\n\t\t// An open gate takes its rows out of the stream's budget: the freshest\n\t\t// output stays visible above the question for context.\n\t\tlet approvalLines: string[] = [];\n\t\tif (this.approval) {\n\t\t\tthis.approval.component.focused = this.focused;\n\t\t\tapprovalLines = this.approval.component.render(inner);\n\t\t}\n\n\t\tconst wrapped: string[] = [];\n\t\tfor (const line of this.lines.toArray()) {\n\t\t\tif (line.length === 0) {\n\t\t\t\twrapped.push(\"\");\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\twrapped.push(...wrapTextWithAnsi(line, inner));\n\t\t}\n\t\tif (this.partial.length > 0) {\n\t\t\twrapped.push(...wrapTextWithAnsi(this.styledPartial(), inner));\n\t\t}\n\t\t// The gate is banded off from the stream by a rule above and below it, and\n\t\t// both count against the budget the same way the gate's own rows do.\n\t\tconst gateBands = approvalLines.length > 0 ? 2 : 0;\n\t\tconst keep = Math.max(0, bodyBudget - approvalLines.length - gateBands);\n\t\tconst body = keep > 0 ? wrapped.slice(-keep) : [];\n\t\tif (body.length === 0 && approvalLines.length === 0) {\n\t\t\tbody.push(theme.fg(\"dim\", \"waiting for events…\"));\n\t\t}\n\n\t\t// The gate's band is accented, not muted: it is the one part of this\n\t\t// panel that is waiting on the reader. The rules are the panel's own —\n\t\t// the gate used to draw them, and drew a second frame inside this one to\n\t\t// do it.\n\t\tif (approvalLines.length > 0) {\n\t\t\tconst gateRule = theme.fg(\"borderAccent\", \"─\".repeat(inner));\n\t\t\treturn [header, rule, ...body, gateRule, ...approvalLines, gateRule];\n\t\t}\n\t\treturn [header, rule, ...body, rule];\n\t}\n}\n"]}