From 9a9351a082940058e7668a247165bd28f51633e7 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 3 Dec 2025 13:41:39 +0900 Subject: [PATCH] feat(feature): add terminal title renaming with session status --- src/features/terminal/index.ts | 1 + src/features/terminal/title.ts | 62 ++++++++++++++++++++ src/index.ts | 101 ++++++++++++++++++++++++++++++++- 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 src/features/terminal/index.ts create mode 100644 src/features/terminal/title.ts diff --git a/src/features/terminal/index.ts b/src/features/terminal/index.ts new file mode 100644 index 0000000..4546fd1 --- /dev/null +++ b/src/features/terminal/index.ts @@ -0,0 +1 @@ +export * from "./title" diff --git a/src/features/terminal/title.ts b/src/features/terminal/title.ts new file mode 100644 index 0000000..58a81dd --- /dev/null +++ b/src/features/terminal/title.ts @@ -0,0 +1,62 @@ +export type SessionStatus = "ready" | "processing" | "tool" | "error" | "idle" + +const STATUS_ICONS: Record = { + ready: "", + processing: "◐", + tool: "⚡", + error: "✖", + idle: "○", +} + +export interface TitleContext { + sessionId: string + sessionTitle?: string + directory?: string + status?: SessionStatus + currentTool?: string + customSuffix?: string +} + +const DEFAULT_TITLE = "OpenCode" +const MAX_TITLE_LENGTH = 30 + +function truncate(str: string, maxLen: number): string { + if (str.length <= maxLen) return str + return str.slice(0, maxLen - 1) + "…" +} + +export function formatTerminalTitle(ctx: TitleContext): string { + const title = ctx.sessionTitle || DEFAULT_TITLE + const truncatedTitle = truncate(title, MAX_TITLE_LENGTH) + + const parts: string[] = ["[OpenCode]", truncatedTitle] + + if (ctx.status) { + parts.push(STATUS_ICONS[ctx.status]) + } + + return parts.join(" ") +} + +function isTmuxEnvironment(): boolean { + return !!process.env.TMUX || process.env.TERM_PROGRAM === "tmux" +} + +export function setTerminalTitle(title: string): void { + // Use stderr to avoid race conditions with stdout buffer + // ANSI escape sequences work on stderr as well + process.stderr.write(`\x1b]0;${title}\x07`) + + if (isTmuxEnvironment()) { + process.stderr.write(`\x1bk${title}\x1b\\`) + } +} + +export function updateTerminalTitle(ctx: TitleContext): void { + const title = formatTerminalTitle(ctx) + setTerminalTitle(title) +} + +export function resetTerminalTitle(): void { + setTerminalTitle(`[OpenCode] ${DEFAULT_TITLE}`) +} diff --git a/src/index.ts b/src/index.ts index 2481472..59b767c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,18 @@ import type { Plugin } from "@opencode-ai/plugin" import { builtinAgents } from "./agents" import { createTodoContinuationEnforcer, createContextWindowMonitorHook } from "./hooks" +import { updateTerminalTitle } from "./features/terminal" const OhMyOpenCodePlugin: Plugin = async (ctx) => { const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx) const contextWindowMonitor = createContextWindowMonitorHook(ctx) + updateTerminalTitle({ sessionId: "main" }) + + let mainSessionID: string | undefined + let currentSessionID: string | undefined + let currentSessionTitle: string | undefined + return { config: async (config) => { config.agent = { @@ -17,9 +24,101 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { event: async (input) => { await todoContinuationEnforcer(input) await contextWindowMonitor.event(input) + + const { event } = input + const props = event.properties as Record | undefined + + if (event.type === "session.created") { + const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined + if (!sessionInfo?.parentID) { + mainSessionID = sessionInfo?.id + currentSessionID = sessionInfo?.id + currentSessionTitle = sessionInfo?.title + updateTerminalTitle({ + sessionId: currentSessionID || "main", + status: "idle", + directory: ctx.directory, + sessionTitle: currentSessionTitle, + }) + } + } + + if (event.type === "session.updated") { + const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined + if (!sessionInfo?.parentID) { + currentSessionID = sessionInfo?.id + currentSessionTitle = sessionInfo?.title + updateTerminalTitle({ + sessionId: currentSessionID || "main", + status: "processing", + directory: ctx.directory, + sessionTitle: currentSessionTitle, + }) + } + } + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined + if (sessionInfo?.id === mainSessionID) { + mainSessionID = undefined + currentSessionID = undefined + currentSessionTitle = undefined + updateTerminalTitle({ + sessionId: "main", + status: "idle", + }) + } + } + + if (event.type === "session.error") { + const sessionID = props?.sessionID as string | undefined + if (sessionID && sessionID === mainSessionID) { + updateTerminalTitle({ + sessionId: sessionID, + status: "error", + directory: ctx.directory, + sessionTitle: currentSessionTitle, + }) + } + } + + if (event.type === "session.idle") { + const sessionID = props?.sessionID as string | undefined + if (sessionID && sessionID === mainSessionID) { + updateTerminalTitle({ + sessionId: sessionID, + status: "idle", + directory: ctx.directory, + sessionTitle: currentSessionTitle, + }) + } + } }, - "tool.execute.after": contextWindowMonitor["tool.execute.after"], + "tool.execute.before": async (input, _output) => { + if (input.sessionID === mainSessionID) { + updateTerminalTitle({ + sessionId: input.sessionID, + status: "tool", + currentTool: input.tool, + directory: ctx.directory, + sessionTitle: currentSessionTitle, + }) + } + }, + + "tool.execute.after": async (input, output) => { + await contextWindowMonitor["tool.execute.after"](input, output) + + if (input.sessionID === mainSessionID) { + updateTerminalTitle({ + sessionId: input.sessionID, + status: "idle", + directory: ctx.directory, + sessionTitle: currentSessionTitle, + }) + } + }, } }