diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx
index 29a3666c034..4619d1765f5 100644
--- a/packages/app/src/components/dialog-select-file.tsx
+++ b/packages/app/src/components/dialog-select-file.tsx
@@ -15,6 +15,7 @@ import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
+import { formatSessionTitle } from "@/utils/session-title"
import { getRelativeTime } from "@/utils/time"
type EntryType = "command" | "file" | "session"
@@ -206,7 +207,7 @@ function createSessionEntries(props: {
.filter((s) => !!s?.id)
.map((s) => ({
id: s.id,
- title: s.title ?? props.language.t("command.session.new"),
+ title: formatSessionTitle(s.title ?? "") || props.language.t("command.session.new"),
description,
directory,
archived: s.time?.archived,
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index cb194052d1e..7c8c03455ea 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -18,6 +18,7 @@ import { useGlobalSync } from "@/context/global-sync"
import { Persist, persisted } from "@/utils/persist"
import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
+import { formatSessionTitle } from "@/utils/session-title"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
@@ -373,7 +374,7 @@ export default function Layout(props: ParentProps) {
const session = store.session.find((s) => s.id === props.sessionID)
const sessionKey = `${directory}:${props.sessionID}`
- const sessionTitle = session?.title ?? language.t("command.session.new")
+ const sessionTitle = formatSessionTitle(session?.title ?? "") || language.t("command.session.new")
const projectName = getFilename(directory)
const description =
e.details.type === "permission.asked"
diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx
index 194f75f815b..68bc1017353 100644
--- a/packages/app/src/pages/layout/sidebar-items.tsx
+++ b/packages/app/src/pages/layout/sidebar-items.tsx
@@ -16,6 +16,7 @@ import { getFilename } from "@opencode-ai/util/path"
import { type Message, type Session, type TextPart, type UserMessage } from "@opencode-ai/sdk/v2/client"
import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js"
import { agentColor } from "@/utils/agent"
+import { formatSessionTitle } from "@/utils/session-title"
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
@@ -122,7 +123,7 @@ const SessionRow = (props: {
- {props.session.title}
+ {formatSessionTitle(props.session.title)}
{(summary) => (
@@ -280,7 +281,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
+
{item}
}
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index b8410903550..4dded9bb2b6 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -12,6 +12,7 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
+import { formatSessionTitle } from "@/utils/session-title"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
@@ -404,7 +405,7 @@ export function MessageTimeline(props: {
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
onDblClick={openTitleEditor}
>
- {titleValue()}
+ {formatSessionTitle(titleValue() ?? "")}
}
>
diff --git a/packages/app/src/utils/session-title.ts b/packages/app/src/utils/session-title.ts
new file mode 100644
index 00000000000..1c3f4277019
--- /dev/null
+++ b/packages/app/src/utils/session-title.ts
@@ -0,0 +1,9 @@
+export function formatSessionTitle(title: string): string {
+ const pipeIndex = title.indexOf("|")
+ if (pipeIndex === -1) return title
+ const group = title.slice(0, pipeIndex).trim()
+ const rest = title.slice(pipeIndex + 1).trim()
+ if (!group) return rest
+ const capitalized = group.charAt(0).toUpperCase() + group.slice(1)
+ return `${capitalized}: ${rest}`
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
index 775969bfcb3..23a9dff592e 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
@@ -34,29 +34,89 @@ export function DialogSessionList() {
const sessions = createMemo(() => searchResults() ?? sync.data.session)
+ function parseSessionTitle(title: string): { group?: string; displayTitle: string } {
+ const pipeIndex = title.indexOf("|")
+ if (pipeIndex === -1) {
+ return { displayTitle: title }
+ }
+
+ const group = title.slice(0, pipeIndex).trim()
+ const displayTitle = title.slice(pipeIndex + 1).trim()
+
+ if (!group) {
+ return { displayTitle }
+ }
+
+ const capitalized = group.charAt(0).toUpperCase() + group.slice(1)
+ return { group: capitalized + ":", displayTitle }
+ }
+
const options = createMemo(() => {
const today = new Date().toDateString()
- return sessions()
- .filter((x) => x.parentID === undefined)
- .toSorted((a, b) => b.time.updated - a.time.updated)
- .map((x) => {
- const date = new Date(x.time.updated)
- let category = date.toDateString()
- if (category === today) {
- category = "Today"
- }
- const isDeleting = toDelete() === x.id
- const status = sync.data.session_status?.[x.id]
- const isWorking = status?.type === "busy"
- return {
- title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
- bg: isDeleting ? theme.error : undefined,
- value: x.id,
- category,
- footer: Locale.time(x.time.updated),
- gutter: isWorking ? : undefined,
- }
- })
+ const allSessions = sessions().filter((x) => x.parentID === undefined)
+
+ // Separate into grouped and ungrouped
+ const grouped: typeof allSessions = []
+ const ungrouped: typeof allSessions = []
+
+ for (const session of allSessions) {
+ const parsed = parseSessionTitle(session.title)
+ if (parsed.group) {
+ grouped.push(session)
+ } else {
+ ungrouped.push(session)
+ }
+ }
+
+ // Sort grouped by group name ASC, then updated DESC
+ grouped.sort((a, b) => {
+ const aParsed = parseSessionTitle(a.title)
+ const bParsed = parseSessionTitle(b.title)
+ const groupCompare = (aParsed.group ?? "").localeCompare(bParsed.group ?? "")
+ if (groupCompare !== 0) return groupCompare
+ return b.time.updated - a.time.updated
+ })
+
+ // Sort ungrouped by updated DESC
+ ungrouped.sort((a, b) => b.time.updated - a.time.updated)
+
+ // Map grouped sessions
+ const groupedOptions = grouped.map((session) => {
+ const parsed = parseSessionTitle(session.title)
+ const isDeleting = toDelete() === session.id
+ const status = sync.data.session_status?.[session.id]
+ const isWorking = status?.type === "busy"
+ return {
+ title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : parsed.displayTitle,
+ bg: isDeleting ? theme.error : undefined,
+ value: session.id,
+ category: parsed.group,
+ footer: Locale.shortDateTime(session.time.updated),
+ gutter: isWorking ? : undefined,
+ }
+ })
+
+ // Map ungrouped sessions
+ const ungroupedOptions = ungrouped.map((session) => {
+ const date = new Date(session.time.updated)
+ let category = date.toDateString()
+ if (category === today) {
+ category = "Today"
+ }
+ const isDeleting = toDelete() === session.id
+ const status = sync.data.session_status?.[session.id]
+ const isWorking = status?.type === "busy"
+ return {
+ title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : session.title,
+ bg: isDeleting ? theme.error : undefined,
+ value: session.id,
+ category,
+ footer: Locale.time(session.time.updated),
+ gutter: isWorking ? : undefined,
+ }
+ })
+
+ return [...groupedOptions, ...ungroupedOptions]
})
onMount(() => {
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
index 0c5ea9a8572..bbddc89b1ed 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
@@ -8,12 +8,17 @@ import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useKeybind } from "../../context/keybind"
import { useTerminalDimensions } from "@opentui/solid"
+import { formatSessionTitle, parseSessionTitleParts } from "@tui/util/session-title"
const Title = (props: { session: Accessor }) => {
const { theme } = useTheme()
+ const parts = createMemo(() => parseSessionTitleParts(props.session().title))
return (
- # {props.session().title}
+ #{" "}
+ {parts().rest}}>
+ {parts().group} {parts().rest}
+
)
}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index f20267e0820..5dc42a38843 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -58,6 +58,7 @@ import type { PromptInfo } from "../../component/prompt/history"
import { DialogConfirm } from "@tui/ui/dialog-confirm"
import { DialogTimeline } from "./dialog-timeline"
import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
+import { formatSessionTitle } from "@tui/util/session-title"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { Sidebar } from "./sidebar"
import { Flag } from "@/flag/flag"
@@ -231,7 +232,7 @@ export function Session() {
const exit = useExit()
createEffect(() => {
- const title = Locale.truncate(session()?.title ?? "", 50)
+ const title = Locale.truncate(formatSessionTitle(session()?.title ?? ""), 50)
const pad = (text: string) => text.padEnd(10, " ")
const weak = (text: string) => UI.Style.TEXT_DIM + pad(text) + UI.Style.TEXT_NORMAL
const logo = UI.logo(" ").split(/\r?\n/)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
index 42ac5fbe080..104f515779e 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
@@ -11,11 +11,13 @@ import { useKeybind } from "../../context/keybind"
import { useDirectory } from "../../context/directory"
import { useKV } from "../../context/kv"
import { TodoItem } from "../../component/todo-item"
+import { formatSessionTitle, parseSessionTitleParts } from "@tui/util/session-title"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const sync = useSync()
const { theme } = useTheme()
const session = createMemo(() => sync.session.get(props.sessionID)!)
+ const titleParts = createMemo(() => parseSessionTitleParts(session().title))
const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? [])
const todo = createMemo(() => sync.data.todo[props.sessionID] ?? [])
const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
@@ -92,7 +94,9 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
- {session().title}
+ {titleParts().rest}}>
+ {titleParts().group} {titleParts().rest}
+
{session().share!.url}
diff --git a/packages/opencode/src/cli/cmd/tui/util/session-title.ts b/packages/opencode/src/cli/cmd/tui/util/session-title.ts
new file mode 100644
index 00000000000..6fea14ab908
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/util/session-title.ts
@@ -0,0 +1,19 @@
+export function formatSessionTitle(title: string): string {
+ const pipeIndex = title.indexOf("|")
+ if (pipeIndex === -1) return title
+ const group = title.slice(0, pipeIndex).trim()
+ const rest = title.slice(pipeIndex + 1).trim()
+ if (!group) return rest
+ const capitalized = group.charAt(0).toUpperCase() + group.slice(1)
+ return `${capitalized}: ${rest}`
+}
+
+export function parseSessionTitleParts(title: string): { group?: string; rest: string } {
+ const pipeIndex = title.indexOf("|")
+ if (pipeIndex === -1) return { rest: title }
+ const group = title.slice(0, pipeIndex).trim()
+ const rest = title.slice(pipeIndex + 1).trim()
+ if (!group) return { rest }
+ const capitalized = group.charAt(0).toUpperCase() + group.slice(1)
+ return { group: capitalized + ":", rest }
+}
diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts
index 653da09a0b7..76e94dff164 100644
--- a/packages/opencode/src/util/locale.ts
+++ b/packages/opencode/src/util/locale.ts
@@ -28,6 +28,27 @@ export namespace Locale {
}
}
+ export function shortDateTime(input: number): string {
+ const date = new Date(input)
+ const now = new Date()
+ const isToday =
+ date.getFullYear() === now.getFullYear() &&
+ date.getMonth() === now.getMonth() &&
+ date.getDate() === now.getDate()
+
+ const timeStr = time(input)
+
+ if (isToday) {
+ return timeStr
+ } else {
+ const dateStr = date.toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ })
+ return `${dateStr} ยท ${timeStr}`
+ }
+ }
+
export function number(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M"