tui.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951
  1. import type {
  2. TuiCommand,
  3. TuiPlugin,
  4. TuiPluginApi,
  5. } from '@opencode-ai/plugin/tui';
  6. import { type ColorInput, parseColor, RGBA } from '@opentui/core';
  7. import type { JSX } from '@opentui/solid';
  8. import { createElement, insert, setProp } from '@opentui/solid';
  9. import { createSignal } from 'solid-js';
  10. import {
  11. ALL_AGENT_NAMES,
  12. DEFAULT_DISABLED_AGENTS,
  13. SUBAGENT_NAMES,
  14. } from './config/constants';
  15. import { loadPluginConfig } from './config/loader';
  16. import {
  17. recordTmuxPane,
  18. removeTmuxPane,
  19. } from './multiplexer/tmux-pane-registry';
  20. import { openPresetManager } from './tui-preset';
  21. import {
  22. readTuiSnapshot,
  23. readTuiSnapshotAsync,
  24. resolveTuiSnapshotRoot,
  25. type TuiSnapshot,
  26. } from './tui-state';
  27. import { isPluginDisabledByEnv } from './utils/env';
  28. const PLUGIN_NAME = 'oh-my-opencode-slim';
  29. const CONFIG_WARNING_COLOR = 'orange';
  30. const FALLBACK_SIDEBAR_AGENTS = SUBAGENT_NAMES.filter(
  31. (agent) =>
  32. agent !== 'councillor' &&
  33. agent !== 'council' &&
  34. !DEFAULT_DISABLED_AGENTS.includes(agent),
  35. );
  36. const BORDER = { type: 'single' };
  37. const TMUX_PANE_HEARTBEAT_MS = 10_000;
  38. const ACTIVITY_FRAME_MS = 100;
  39. const ACTIVITY_FRAMES = [
  40. '⠋',
  41. '⠙',
  42. '⠹',
  43. '⠸',
  44. '⠼',
  45. '⠴',
  46. '⠦',
  47. '⠧',
  48. '⠇',
  49. '⠏',
  50. ] as const;
  51. type Child = JSX.Element | string | number | null | undefined | false;
  52. async function readPackageVersion(): Promise<string | undefined> {
  53. try {
  54. const packageJson = (await Bun.file(
  55. new URL('../package.json', import.meta.url),
  56. ).json()) as { version?: unknown };
  57. return typeof packageJson.version === 'string'
  58. ? packageJson.version
  59. : undefined;
  60. } catch {
  61. return undefined;
  62. }
  63. }
  64. function element(
  65. tag: string,
  66. props: Record<string, unknown>,
  67. children: Child[] = [],
  68. ) {
  69. const node = createElement(tag);
  70. for (const [key, value] of Object.entries(props)) {
  71. if (value !== undefined) setProp(node, key, value);
  72. }
  73. for (const child of children) {
  74. if (child === null || child === undefined || child === false) continue;
  75. insert(node, child);
  76. }
  77. return node as unknown as JSX.Element;
  78. }
  79. function text(props: Record<string, unknown>, children: Child[]) {
  80. return element('text', props, children);
  81. }
  82. function box(props: Record<string, unknown>, children: Child[] = []) {
  83. return element('box', props, children);
  84. }
  85. function reactiveElement(render: () => JSX.Element): JSX.Element {
  86. const root = box({ width: '100%', flexDirection: 'column' });
  87. insert(root, render);
  88. return root;
  89. }
  90. function getTuiDirectory(api: {
  91. state?: { path?: { directory?: string } };
  92. }): string {
  93. return api.state?.path?.directory ?? process.cwd();
  94. }
  95. export interface ActiveTmuxPaneRegistration {
  96. sessionId?: string;
  97. paneId?: string;
  98. ownerPid: number;
  99. lastRecordedAt: number;
  100. }
  101. /** Route shapes accepted by `syncTmuxPaneRegistration`: v1 `{ name, params }` and v2 `{ type, sessionID }`. */
  102. export type TuiRouteView =
  103. | {
  104. name?: string;
  105. params?: { sessionID?: unknown };
  106. }
  107. | {
  108. type?: string;
  109. sessionID?: string;
  110. };
  111. function resolveRouteSessionId(route: TuiRouteView): string | undefined {
  112. const view = route as {
  113. name?: string;
  114. params?: { sessionID?: unknown };
  115. type?: string;
  116. sessionID?: string;
  117. };
  118. if (view.name === 'session' && typeof view.params?.sessionID === 'string') {
  119. return view.params.sessionID;
  120. }
  121. if (view.type === 'session' && typeof view.sessionID === 'string') {
  122. return view.sessionID;
  123. }
  124. return undefined;
  125. }
  126. function clearTmuxPaneRegistration(
  127. registration: ActiveTmuxPaneRegistration,
  128. ): void {
  129. if (registration.sessionId && registration.paneId) {
  130. removeTmuxPane(
  131. registration.sessionId,
  132. registration.paneId,
  133. registration.ownerPid,
  134. );
  135. }
  136. registration.sessionId = undefined;
  137. registration.paneId = undefined;
  138. registration.lastRecordedAt = 0;
  139. }
  140. export function syncTmuxPaneRegistration(
  141. route: TuiRouteView,
  142. registration: ActiveTmuxPaneRegistration,
  143. now = Date.now(),
  144. ): void {
  145. const paneId = process.env.TMUX_PANE;
  146. const sessionId = resolveRouteSessionId(route);
  147. const unchanged =
  148. registration.sessionId === sessionId && registration.paneId === paneId;
  149. if (!paneId || !sessionId) {
  150. clearTmuxPaneRegistration(registration);
  151. return;
  152. }
  153. if (unchanged && now - registration.lastRecordedAt < TMUX_PANE_HEARTBEAT_MS) {
  154. return;
  155. }
  156. if (!unchanged) clearTmuxPaneRegistration(registration);
  157. if (recordTmuxPane(sessionId, paneId, registration.ownerPid)) {
  158. registration.sessionId = sessionId;
  159. registration.paneId = paneId;
  160. registration.lastRecordedAt = now;
  161. }
  162. }
  163. export function splitSidebarModelId(model: string): {
  164. provider?: string;
  165. model: string;
  166. } {
  167. const slashIndex = model.indexOf('/');
  168. if (slashIndex === -1) {
  169. return { model };
  170. }
  171. return {
  172. provider: model.slice(0, slashIndex),
  173. model: model.slice(slashIndex + 1),
  174. };
  175. }
  176. export function getSidebarAgentNames(snapshot: TuiSnapshot): string[] {
  177. const configuredAgents = Object.keys(snapshot.agentModels);
  178. return configuredAgents.length > 0
  179. ? configuredAgents
  180. : FALLBACK_SIDEBAR_AGENTS;
  181. }
  182. type AgentListFn = (input?: unknown) => Promise<unknown>;
  183. function asFunction(value: unknown): AgentListFn | undefined {
  184. return typeof value === 'function' ? (value as AgentListFn) : undefined;
  185. }
  186. function unwrapAgentList(response: unknown): unknown[] {
  187. if (Array.isArray(response)) return response;
  188. if (!response || typeof response !== 'object') return [];
  189. const data = (response as { data?: unknown }).data;
  190. if (Array.isArray(data)) return data;
  191. if (data && typeof data === 'object') {
  192. const nested = (data as { data?: unknown }).data;
  193. if (Array.isArray(nested)) return nested;
  194. }
  195. return [];
  196. }
  197. function remoteAgentName(entry: unknown): string | undefined {
  198. if (!entry || typeof entry !== 'object') return undefined;
  199. const rec = entry as { name?: unknown; id?: unknown };
  200. if (typeof rec.name === 'string') return rec.name;
  201. if (typeof rec.id === 'string') return rec.id;
  202. return undefined;
  203. }
  204. function remoteModelId(model: unknown): string | undefined {
  205. if (!model || typeof model !== 'object') return undefined;
  206. const rec = model as {
  207. providerID?: unknown;
  208. modelID?: unknown;
  209. id?: unknown;
  210. };
  211. if (typeof rec.providerID !== 'string') return undefined;
  212. const id =
  213. typeof rec.modelID === 'string'
  214. ? rec.modelID
  215. : typeof rec.id === 'string'
  216. ? rec.id
  217. : undefined;
  218. return id ? `${rec.providerID}/${id}` : undefined;
  219. }
  220. function modelsFromAgentList(response: unknown): Record<string, string> {
  221. const models: Record<string, string> = {};
  222. for (const entry of unwrapAgentList(response)) {
  223. const name = remoteAgentName(entry);
  224. const model = remoteModelId(
  225. (entry as { model?: unknown } | undefined)?.model,
  226. );
  227. if (!name || !model) continue;
  228. if ((ALL_AGENT_NAMES as readonly string[]).includes(name)) {
  229. models[name] = model;
  230. }
  231. }
  232. return models;
  233. }
  234. /**
  235. * Remote-attach fallback (#1133): the server-side plugin writes
  236. * tui-state.json on the server's filesystem, which a remote TUI cannot
  237. * see, so every model renders as "pending". Resolve agent models through
  238. * the host SDK instead. Only fills gaps — local snapshot entries win.
  239. *
  240. * v1 TUI (`api.client`, `@opencode-ai/sdk/v2`): `app.agents({ directory })`
  241. * with `{ name, model: { providerID, modelID } }`.
  242. * v2 TUI: `agent.list({ location: { directory } })` or
  243. * `v2.agent.list(...)` with `{ id, model: { providerID, id } }`.
  244. */
  245. export async function fetchRemoteAgentModels(
  246. client: unknown,
  247. directory: string,
  248. ): Promise<Record<string, string>> {
  249. const rec = client as
  250. | {
  251. app?: { agents?: unknown };
  252. agent?: { list?: unknown };
  253. v2?: { agent?: { list?: unknown } };
  254. }
  255. | undefined;
  256. if (!rec) return {};
  257. try {
  258. const v1Agents = asFunction(rec.app?.agents);
  259. if (v1Agents) {
  260. return modelsFromAgentList(await v1Agents.call(rec.app, { directory }));
  261. }
  262. const v2Receiver = rec.agent ?? rec.v2?.agent;
  263. const v2List = asFunction(v2Receiver?.list);
  264. if (!v2List) return {};
  265. return modelsFromAgentList(
  266. await v2List.call(v2Receiver, { location: { directory } }),
  267. );
  268. } catch {
  269. return {};
  270. }
  271. }
  272. /** Local snapshot entries win; remote fills empty/missing agent models (#1133). */
  273. export function applyRemoteAgentModels(
  274. snapshot: TuiSnapshot,
  275. remote: Record<string, string>,
  276. ): TuiSnapshot {
  277. if (Object.keys(remote).length === 0) return snapshot;
  278. return {
  279. ...snapshot,
  280. agentModels: { ...remote, ...snapshot.agentModels },
  281. };
  282. }
  283. const REMOTE_RETRY_MS = 5_000;
  284. interface RemoteModelCache {
  285. directory?: string;
  286. models?: Record<string, string>;
  287. at?: number;
  288. }
  289. async function hydrateRemoteModels(
  290. snapshot: TuiSnapshot,
  291. client: unknown,
  292. directory: string,
  293. cache: RemoteModelCache,
  294. ): Promise<TuiSnapshot> {
  295. if (Object.keys(snapshot.agentModels).length > 0) return snapshot;
  296. const now = Date.now();
  297. const cached =
  298. cache.directory === directory && cache.models !== undefined
  299. ? cache.models
  300. : undefined;
  301. const cacheFresh =
  302. cached !== undefined &&
  303. (Object.keys(cached).length > 0 ||
  304. (cache.at !== undefined && now - cache.at < REMOTE_RETRY_MS));
  305. if (cached !== undefined && cacheFresh) {
  306. return applyRemoteAgentModels(snapshot, cached);
  307. }
  308. const models = await fetchRemoteAgentModels(client, directory);
  309. cache.directory = directory;
  310. cache.models = models;
  311. cache.at = now;
  312. return applyRemoteAgentModels(snapshot, models);
  313. }
  314. /** Skip overlapping sidebar refreshes so a slow host fetch cannot pile up. */
  315. export function createSerializedRefresh(run: () => Promise<void>): () => void {
  316. let inFlight = false;
  317. return () => {
  318. if (inFlight) return;
  319. inFlight = true;
  320. void run()
  321. .catch(() => {
  322. // Ignore render errors; this is best-effort live status.
  323. })
  324. .finally(() => {
  325. inFlight = false;
  326. });
  327. };
  328. }
  329. /** Drop a refresh whose directory changed while the host fetch was in flight. */
  330. export function isRefreshCurrent(
  331. startedDirectory: string,
  332. currentDirectory: string,
  333. ): boolean {
  334. return startedDirectory === currentDirectory;
  335. }
  336. export function getActiveSidebarAgentNames(
  337. snapshot: TuiSnapshot,
  338. visibleRootID?: string,
  339. ): ReadonlySet<string> {
  340. const names = new Set<string>();
  341. // Both sides resolve against the same persistent sessionParents index:
  342. // the visible route session (possibly a child) to its root, and every
  343. // active session to its root. This keeps spinners scoped to the
  344. // conversation this window is viewing (#1147) — shared v2 daemons record
  345. // every window's subagents from one process, so only the session tree
  346. // can separate them — and a late-learned link re-roots both sides
  347. // consistently. Without a visible session (home route) keep the union.
  348. const root =
  349. visibleRootID === undefined
  350. ? undefined
  351. : resolveTuiSnapshotRoot(snapshot, visibleRootID);
  352. for (const [sessionID, agentName] of Object.entries(
  353. snapshot.activeSessions,
  354. )) {
  355. if (
  356. root === undefined ||
  357. resolveTuiSnapshotRoot(snapshot, sessionID) === root
  358. ) {
  359. names.add(agentName);
  360. }
  361. }
  362. return names;
  363. }
  364. export function getSidebarActivityIndicator(
  365. active: boolean,
  366. now = Date.now(),
  367. ): string {
  368. if (!active) return ' ';
  369. const frame = Math.floor(now / ACTIVITY_FRAME_MS) % ACTIVITY_FRAMES.length;
  370. return ACTIVITY_FRAMES[frame];
  371. }
  372. interface AgentRowTheme {
  373. accent: unknown;
  374. text: unknown;
  375. textMuted: unknown;
  376. }
  377. function activityIndicator(
  378. active: boolean,
  379. now: number,
  380. theme: AgentRowTheme,
  381. ): JSX.Element {
  382. return text(
  383. {
  384. fg: active ? (theme.accent ?? theme.text) : theme.textMuted,
  385. width: 2,
  386. },
  387. [getSidebarActivityIndicator(active, now)],
  388. );
  389. }
  390. function agentRow(
  391. label: string,
  392. model: string,
  393. variant: string | undefined,
  394. active: boolean,
  395. now: number,
  396. theme: AgentRowTheme,
  397. ): JSX.Element {
  398. const modelParts = splitSidebarModelId(model);
  399. const detailRows: JSX.Element[] = [];
  400. function detailRow(fieldLabel: string, value: string) {
  401. return box({ width: '100%', flexDirection: 'row', paddingLeft: 2 }, [
  402. text({ fg: theme.textMuted, width: 9 }, [fieldLabel]),
  403. text({ fg: theme.textMuted }, [value]),
  404. ]);
  405. }
  406. if (modelParts.provider) {
  407. detailRows.push(detailRow('provider', modelParts.provider));
  408. }
  409. detailRows.push(detailRow('model', modelParts.model));
  410. if (variant) {
  411. detailRows.push(detailRow('variant', variant));
  412. }
  413. return box({ width: '100%', flexDirection: 'column', marginBottom: 1 }, [
  414. box({ width: '100%', flexDirection: 'row' }, [
  415. text({ fg: theme.textMuted, width: 14 }, [label]),
  416. activityIndicator(active, now, theme),
  417. ]),
  418. ...detailRows,
  419. ]);
  420. }
  421. function compactAgentRow(
  422. label: string,
  423. model: string,
  424. _variant: string | undefined,
  425. active: boolean,
  426. now: number,
  427. theme: AgentRowTheme,
  428. ): JSX.Element {
  429. const modelName = splitSidebarModelId(model).model;
  430. return box(
  431. {
  432. width: '100%',
  433. flexDirection: 'row',
  434. justifyContent: 'space-between',
  435. },
  436. [
  437. box({ width: 16, flexShrink: 0, flexDirection: 'row' }, [
  438. text({ fg: theme.textMuted, width: 14 }, [label]),
  439. activityIndicator(active, now, theme),
  440. ]),
  441. text(
  442. {
  443. fg: theme.textMuted,
  444. wrapMode: 'none',
  445. truncate: true,
  446. flexShrink: 1,
  447. },
  448. [modelName],
  449. ),
  450. ],
  451. );
  452. }
  453. export function getContrastForeground(
  454. accent: unknown,
  455. themeText: unknown,
  456. themeBackground: unknown,
  457. ): unknown {
  458. if (!accent) return themeText;
  459. let accentRgba: RGBA;
  460. try {
  461. accentRgba = parseColor(accent as ColorInput);
  462. } catch {
  463. return themeText;
  464. }
  465. // Calculate relative luminance: R, G, B are in range 0..1
  466. const luminance =
  467. 0.299 * accentRgba.r + 0.587 * accentRgba.g + 0.114 * accentRgba.b;
  468. if (luminance > 0.5) {
  469. // Light accent bg -> we need a dark fg.
  470. // Let's use themeBackground if it exists, is resolved, and not transparent.
  471. if (themeBackground) {
  472. try {
  473. const bgRgba = parseColor(themeBackground as ColorInput);
  474. if (bgRgba.a !== 0) {
  475. const bgLum = 0.299 * bgRgba.r + 0.587 * bgRgba.g + 0.114 * bgRgba.b;
  476. if (bgLum < 0.5) {
  477. return themeBackground;
  478. }
  479. }
  480. } catch {
  481. // ignore and fallback
  482. }
  483. }
  484. return RGBA.fromInts(0, 0, 0);
  485. }
  486. // Dark accent bg -> we need a light fg.
  487. // Let's use themeText if it exists and is light.
  488. if (themeText) {
  489. try {
  490. const textRgba = parseColor(themeText as ColorInput);
  491. const textLum =
  492. 0.299 * textRgba.r + 0.587 * textRgba.g + 0.114 * textRgba.b;
  493. if (textLum > 0.5) {
  494. return themeText;
  495. }
  496. } catch {
  497. // ignore and fallback
  498. }
  499. }
  500. return RGBA.fromInts(255, 255, 255);
  501. }
  502. function renderSidebar(
  503. snapshot: TuiSnapshot,
  504. version: string,
  505. theme: {
  506. accent: unknown;
  507. background: unknown;
  508. borderActive: unknown;
  509. text: unknown;
  510. textMuted: unknown;
  511. },
  512. configInvalid: boolean,
  513. compactSidebar: boolean,
  514. now = Date.now(),
  515. visibleRootID?: string,
  516. ): JSX.Element {
  517. const configStatusRow = buildConfigStatusRow(configInvalid, theme);
  518. const activeAgents = getActiveSidebarAgentNames(snapshot, visibleRootID);
  519. return box(
  520. {
  521. width: '100%',
  522. flexDirection: 'column',
  523. border: BORDER,
  524. borderColor: theme.borderActive,
  525. paddingTop: 1,
  526. paddingBottom: 1,
  527. paddingLeft: 1,
  528. paddingRight: 1,
  529. },
  530. [
  531. box(
  532. {
  533. width: '100%',
  534. flexDirection: 'row',
  535. justifyContent: 'space-between',
  536. alignItems: 'center',
  537. },
  538. [
  539. box(
  540. { paddingLeft: 1, paddingRight: 1, backgroundColor: theme.accent },
  541. [
  542. text(
  543. {
  544. fg: getContrastForeground(
  545. theme.accent,
  546. theme.text,
  547. theme.background,
  548. ),
  549. },
  550. ['OMO-Slim'],
  551. ),
  552. ],
  553. ),
  554. text({ fg: theme.textMuted }, [`v${version}`]),
  555. ],
  556. ),
  557. configStatusRow,
  558. box({ width: '100%', marginTop: 1 }, [
  559. text({ fg: theme.text }, ['Agents']),
  560. ]),
  561. ...getSidebarAgentNames(snapshot).map((agentName) => {
  562. const model = snapshot.agentModels[agentName] ?? 'pending';
  563. const variant = snapshot.agentVariants[agentName];
  564. const active = activeAgents.has(agentName);
  565. if (compactSidebar) {
  566. return compactAgentRow(agentName, model, variant, active, now, theme);
  567. }
  568. return agentRow(agentName, model, variant, active, now, theme);
  569. }),
  570. ],
  571. );
  572. }
  573. function buildConfigStatusRow(
  574. configInvalid: boolean,
  575. theme: { textMuted: unknown },
  576. ): JSX.Element | null {
  577. if (!configInvalid) return null;
  578. return box(
  579. {
  580. width: '100%',
  581. flexDirection: 'column',
  582. marginTop: 1,
  583. marginBottom: 1,
  584. },
  585. [
  586. text({ fg: CONFIG_WARNING_COLOR }, ['Config invalid']),
  587. text({ fg: theme.textMuted }, ['Run doctor for details']),
  588. ],
  589. );
  590. }
  591. function readConfigState(directory: string): {
  592. configInvalid: boolean;
  593. compactSidebar: boolean;
  594. } {
  595. let configInvalid = false;
  596. const config = loadPluginConfig(directory, {
  597. silent: true,
  598. onWarning: (warning) => {
  599. // Only genuinely broken configs (parse/load/schema failures) mark the
  600. // sidebar invalid. Benign deprecation notices (deprecated-key) and
  601. // missing-preset do not, otherwise a config that loads fine would be
  602. // shown as "Config invalid".
  603. if (
  604. warning.kind === 'invalid-json' ||
  605. warning.kind === 'invalid-schema' ||
  606. warning.kind === 'read-error'
  607. ) {
  608. configInvalid = true;
  609. }
  610. },
  611. });
  612. const compactSidebar = config.compactSidebar ?? true;
  613. return { configInvalid, compactSidebar };
  614. }
  615. export function readConfigInvalid(directory: string): boolean {
  616. return readConfigState(directory).configInvalid;
  617. }
  618. export function readCompactSidebar(directory: string): boolean {
  619. return readConfigState(directory).compactSidebar;
  620. }
  621. // Mirrors the OpenCode v2 TUI context surface (dist/tui/context.d.ts);
  622. // declared locally because the pinned @opencode-ai/plugin dep ships v1
  623. // types only.
  624. interface V2TuiThemeTokens {
  625. text: { default: unknown; subdued: unknown };
  626. background: { default: unknown };
  627. border: { default: unknown };
  628. }
  629. interface V2TuiSlotClaim {
  630. append?: string;
  631. prepend?: string;
  632. before?: string;
  633. after?: string;
  634. replace?: string;
  635. render: (input: { sessionID: string }) => JSX.Element;
  636. }
  637. interface V2TuiContext {
  638. location?: { directory: string };
  639. client?: unknown;
  640. renderer: { requestRender: () => void };
  641. theme: V2TuiThemeTokens;
  642. ui: {
  643. slot: (claim: V2TuiSlotClaim) => () => void;
  644. router: { current: () => { type?: string; sessionID?: string } };
  645. };
  646. }
  647. /** Map v2 theme tokens onto the flat shape `renderSidebar` consumes (v2 has no `accent` token). */
  648. function v2ThemeView(theme: V2TuiThemeTokens): {
  649. accent: undefined;
  650. background: unknown;
  651. borderActive: unknown;
  652. text: unknown;
  653. textMuted: unknown;
  654. } {
  655. return {
  656. accent: undefined,
  657. background: theme.background.default,
  658. borderActive: theme.border.default,
  659. text: theme.text.default,
  660. textMuted: theme.text.subdued,
  661. };
  662. }
  663. /**
  664. * V2 entry point: sidebar slot + refresh loop; returns cleanup.
  665. * `/preset` stays v1-only (`api.command` is absent on v2).
  666. */
  667. async function setup(ctx: V2TuiContext): Promise<undefined | (() => void)> {
  668. if (isPluginDisabledByEnv()) return;
  669. const version = (await readPackageVersion()) ?? 'dev';
  670. let configDirectory = ctx.location?.directory ?? process.cwd();
  671. let { configInvalid, compactSidebar } = readConfigState(configDirectory);
  672. const [snapshot, setSnapshot] = createSignal(
  673. readTuiSnapshot(configDirectory),
  674. );
  675. const [animationNow, setAnimationNow] = createSignal(Date.now());
  676. const tmuxRegistration: ActiveTmuxPaneRegistration = {
  677. ownerPid: process.pid,
  678. lastRecordedAt: 0,
  679. };
  680. syncTmuxPaneRegistration(ctx.ui.router.current(), tmuxRegistration);
  681. let disposed = false;
  682. const remoteCache: RemoteModelCache = {};
  683. const refreshSidebar = async () => {
  684. if (disposed) return;
  685. const currentDirectory = ctx.location?.directory ?? process.cwd();
  686. syncTmuxPaneRegistration(ctx.ui.router.current(), tmuxRegistration);
  687. let nextSnapshot = await readTuiSnapshotAsync(currentDirectory);
  688. if (disposed) return;
  689. if (currentDirectory !== configDirectory) {
  690. configDirectory = currentDirectory;
  691. ({ configInvalid, compactSidebar } = readConfigState(configDirectory));
  692. }
  693. nextSnapshot = await hydrateRemoteModels(
  694. nextSnapshot,
  695. ctx.client,
  696. currentDirectory,
  697. remoteCache,
  698. );
  699. if (disposed) return;
  700. if (
  701. !isRefreshCurrent(
  702. currentDirectory,
  703. ctx.location?.directory ?? process.cwd(),
  704. )
  705. ) {
  706. return;
  707. }
  708. setSnapshot(nextSnapshot);
  709. ctx.renderer.requestRender();
  710. };
  711. const scheduleRefresh = createSerializedRefresh(refreshSidebar);
  712. scheduleRefresh();
  713. const renderTimer = setInterval(scheduleRefresh, 1000);
  714. const animationTimer = setInterval(() => {
  715. // Same scoping as the render: hidden foreign-conversation activity
  716. // must not keep this window's sidebar rerendering every frame.
  717. if (
  718. !disposed &&
  719. getActiveSidebarAgentNames(snapshot(), visibleSession()).size > 0
  720. ) {
  721. setAnimationNow(Date.now());
  722. }
  723. }, ACTIVITY_FRAME_MS);
  724. const visibleSession = () => resolveRouteSessionId(ctx.ui.router.current());
  725. const disposeSlot = ctx.ui.slot({
  726. append: 'sidebar.content',
  727. render: () =>
  728. reactiveElement(() =>
  729. renderSidebar(
  730. snapshot(),
  731. version,
  732. v2ThemeView(ctx.theme),
  733. configInvalid,
  734. compactSidebar,
  735. animationNow(),
  736. visibleSession(),
  737. ),
  738. ),
  739. });
  740. return () => {
  741. disposed = true;
  742. disposeSlot();
  743. clearInterval(renderTimer);
  744. clearInterval(animationTimer);
  745. clearTmuxPaneRegistration(tmuxRegistration);
  746. };
  747. }
  748. /**
  749. * Build the TUI slash command for `/preset`. Registered via the legacy
  750. * `api.command` API (still populated in OpenCode 1.18 for v1 plugins). If the
  751. * API is unavailable the command is simply not registered and `/preset` is a
  752. * no-op.
  753. *
  754. * The command opens a three-level preset manager (list → edit → agent model)
  755. * implemented in `src/tui-preset.ts`. Like the built-in `/models`, it is pure
  756. * TUI and triggers no LLM turn.
  757. */
  758. function buildPresetCommand(
  759. api: TuiPluginApi,
  760. directoryGetter: () => string,
  761. snapshotRef: { snapshot: TuiSnapshot },
  762. ): TuiCommand {
  763. return {
  764. title: 'Switch preset',
  765. value: 'preset',
  766. description: 'Switch agent presets at runtime (e.g. /preset cheap)',
  767. slash: { name: 'preset' },
  768. onSelect: () => {
  769. openPresetManager(api, directoryGetter(), snapshotRef);
  770. },
  771. };
  772. }
  773. /**
  774. * Dual contract: v1 hosts validate `{ id, tui }`, opencode2 validates
  775. * `{ id, setup }`; both ignore extra keys. Fixes #1002.
  776. */
  777. interface TuiDualContractModule {
  778. id: string;
  779. tui: TuiPlugin;
  780. setup: (ctx: V2TuiContext) => Promise<undefined | (() => void)>;
  781. }
  782. const plugin: TuiDualContractModule = {
  783. id: `${PLUGIN_NAME}:tui`,
  784. tui: async (api, _options, meta) => {
  785. if (isPluginDisabledByEnv()) return;
  786. const version = meta.version ?? (await readPackageVersion()) ?? 'dev';
  787. let configDirectory = getTuiDirectory(api);
  788. let { configInvalid, compactSidebar } = readConfigState(configDirectory);
  789. const [snapshot, setSnapshot] = createSignal(
  790. readTuiSnapshot(configDirectory),
  791. );
  792. const [animationNow, setAnimationNow] = createSignal(Date.now());
  793. const tmuxRegistration: ActiveTmuxPaneRegistration = {
  794. ownerPid: process.pid,
  795. lastRecordedAt: 0,
  796. };
  797. syncTmuxPaneRegistration(api.route.current, tmuxRegistration);
  798. const remoteCache: RemoteModelCache = {};
  799. const refreshSidebar = async () => {
  800. const currentDirectory = getTuiDirectory(api);
  801. syncTmuxPaneRegistration(api.route.current, tmuxRegistration);
  802. let nextSnapshot = await readTuiSnapshotAsync(currentDirectory);
  803. if (currentDirectory !== configDirectory) {
  804. configDirectory = currentDirectory;
  805. ({ configInvalid, compactSidebar } = readConfigState(configDirectory));
  806. }
  807. nextSnapshot = await hydrateRemoteModels(
  808. nextSnapshot,
  809. (api as { client?: unknown }).client,
  810. currentDirectory,
  811. remoteCache,
  812. );
  813. if (!isRefreshCurrent(currentDirectory, getTuiDirectory(api))) return;
  814. setSnapshot(nextSnapshot);
  815. api.renderer.requestRender();
  816. };
  817. const scheduleRefresh = createSerializedRefresh(refreshSidebar);
  818. scheduleRefresh();
  819. const renderTimer = setInterval(scheduleRefresh, 1000);
  820. const animationTimer = setInterval(() => {
  821. // Same scoping as the render: hidden foreign-conversation activity
  822. // must not keep this window's sidebar rerendering every frame.
  823. if (
  824. getActiveSidebarAgentNames(
  825. snapshot(),
  826. resolveRouteSessionId(api.route.current),
  827. ).size > 0
  828. ) {
  829. setAnimationNow(Date.now());
  830. }
  831. }, ACTIVITY_FRAME_MS);
  832. api.lifecycle.onDispose(() => {
  833. clearInterval(renderTimer);
  834. clearInterval(animationTimer);
  835. clearTmuxPaneRegistration(tmuxRegistration);
  836. });
  837. api.slots.register({
  838. order: 900,
  839. slots: {
  840. sidebar_content() {
  841. return reactiveElement(() =>
  842. renderSidebar(
  843. snapshot(),
  844. version,
  845. api.theme.current,
  846. configInvalid,
  847. compactSidebar,
  848. animationNow(),
  849. resolveRouteSessionId(api.route.current),
  850. ),
  851. );
  852. },
  853. },
  854. });
  855. // `/preset` is a pure TUI slash command (like the built-in `/models`):
  856. // it opens a picker, switches the preset via on-disk state, and never
  857. // sends a message to the server or triggers an LLM turn. The legacy
  858. // `api.command` API is still populated in OpenCode 1.18; if it is absent
  859. // (e.g. a future v2-only build), registration is skipped gracefully.
  860. if (api.command) {
  861. const snapshotRef: { snapshot: TuiSnapshot } = {
  862. get snapshot() {
  863. return snapshot();
  864. },
  865. set snapshot(value: TuiSnapshot) {
  866. setSnapshot(value);
  867. },
  868. };
  869. const disposeCommands = api.command.register(() => [
  870. buildPresetCommand(api, () => configDirectory, snapshotRef),
  871. ]);
  872. api.lifecycle.onDispose(disposeCommands);
  873. }
  874. },
  875. setup,
  876. };
  877. export default plugin;