From 89a7371b87f6002d1a7397c490dd8beae2707314 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Tue, 17 Feb 2026 12:01:48 -0500 Subject: [PATCH 1/4] feat(cli): implement session grouping using prefixes Sessions with titles containing a pipe character (e.g., 'Features | Some description') are now grouped by their prefix. Grouped sessions appear above regular date-grouped sessions, with each group sorted by last update time. - Add shortDateTime() utility for abbreviated date/time display - Parse session titles to extract group prefix and display title - Separate sessions into grouped and ungrouped categories - Grouped sessions show full date/time, ungrouped show just time Resolves #187 --- .../cmd/tui/component/dialog-session-list.tsx | 101 ++++++++++++++---- packages/opencode/src/util/locale.ts | 21 ++++ 2 files changed, 101 insertions(+), 21 deletions(-) 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..a3512c1de53 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,88 @@ 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 } + } + + return { group, 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/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" From 14a32e878c8233cf44f72f111c3cc9487ae1d6c0 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Fri, 20 Feb 2026 18:10:41 -0500 Subject: [PATCH 2/4] Revise session title grouping display format --- packages/app/src/components/dialog-select-file.tsx | 3 ++- packages/app/src/pages/layout.tsx | 3 ++- packages/app/src/pages/layout/sidebar-items.tsx | 5 +++-- packages/app/src/pages/session/message-timeline.tsx | 3 ++- packages/app/src/utils/session-title.ts | 9 +++++++++ .../src/cli/cmd/tui/component/dialog-session-list.tsx | 3 ++- 6 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 packages/app/src/utils/session-title.ts 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 62094a6e428..12d71626809 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" @@ -372,7 +373,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 567ef5fc876..f1f4d5f09c8 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -11,6 +11,7 @@ import { SessionTurn } from "@opencode-ai/ui/session-turn" 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" @@ -400,7 +401,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 a3512c1de53..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 @@ -47,7 +47,8 @@ export function DialogSessionList() { return { displayTitle } } - return { group, displayTitle } + const capitalized = group.charAt(0).toUpperCase() + group.slice(1) + return { group: capitalized + ":", displayTitle } } const options = createMemo(() => { From 5136cf8f01f969b7725de68bbc645f44ad646c39 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Fri, 20 Feb 2026 18:53:14 -0500 Subject: [PATCH 3/4] Apply session title formatting to TUI header and sidebar --- .../opencode/src/cli/cmd/tui/routes/session/header.tsx | 3 ++- .../opencode/src/cli/cmd/tui/routes/session/index.tsx | 3 ++- .../opencode/src/cli/cmd/tui/routes/session/sidebar.tsx | 3 ++- packages/opencode/src/cli/cmd/tui/util/session-title.ts | 9 +++++++++ 4 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/util/session-title.ts 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..9f57251d5a0 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,13 @@ 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 } from "@tui/util/session-title" const Title = (props: { session: Accessor }) => { const { theme } = useTheme() return ( - # {props.session().title} + # {formatSessionTitle(props.session().title)} ) } 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 f5a7f6f6ca4..77c0b419742 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" @@ -228,7 +229,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..9841660ca2b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -11,6 +11,7 @@ import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" import { TodoItem } from "../../component/todo-item" +import { formatSessionTitle } from "@tui/util/session-title" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() @@ -92,7 +93,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { - {session().title} + {formatSessionTitle(session().title)} {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..1c3f4277019 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/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}` +} From 78749cd8e78b1d1c7926768562cdb34aba5dcdbb Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Fri, 20 Feb 2026 19:11:08 -0500 Subject: [PATCH 4/4] Display group portion of session title in bold in TUI --- .../opencode/src/cli/cmd/tui/routes/session/header.tsx | 8 ++++++-- .../src/cli/cmd/tui/routes/session/sidebar.tsx | 7 +++++-- .../opencode/src/cli/cmd/tui/util/session-title.ts | 10 ++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) 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 9f57251d5a0..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,13 +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 } from "@tui/util/session-title" +import { formatSessionTitle, parseSessionTitleParts } from "@tui/util/session-title" const Title = (props: { session: Accessor }) => { const { theme } = useTheme() + const parts = createMemo(() => parseSessionTitleParts(props.session().title)) return ( - # {formatSessionTitle(props.session().title)} + #{" "} + {parts().rest}}> + {parts().group} {parts().rest} + ) } 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 9841660ca2b..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,12 +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 } from "@tui/util/session-title" +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] ?? []) @@ -93,7 +94,9 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { - {formatSessionTitle(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 index 1c3f4277019..6fea14ab908 100644 --- a/packages/opencode/src/cli/cmd/tui/util/session-title.ts +++ b/packages/opencode/src/cli/cmd/tui/util/session-title.ts @@ -7,3 +7,13 @@ export function formatSessionTitle(title: string): string { 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 } +}