Skip to content
Open
3 changes: 2 additions & 1 deletion packages/app/src/components/dialog-select-file.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/pages/layout/sidebar-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -122,7 +123,7 @@ const SessionRow = (props: {
</Switch>
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title}
{formatSessionTitle(props.session.title)}
</span>
<Show when={props.session.summary}>
{(summary) => (
Expand Down Expand Up @@ -280,7 +281,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
<Show
when={hoverEnabled()}
fallback={
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
<Tooltip placement={props.mobile ? "bottom" : "right"} value={formatSessionTitle(props.session.title)} gutter={10}>
{item}
</Tooltip>
}
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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() ?? "")}
</h1>
}
>
Expand Down
9 changes: 9 additions & 0 deletions packages/app/src/utils/session-title.ts
Original file line number Diff line number Diff line change
@@ -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}`
}
102 changes: 81 additions & 21 deletions packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? <Spinner /> : 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 ? <Spinner /> : 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 ? <Spinner /> : undefined,
}
})

return [...groupedOptions, ...ungroupedOptions]
})

onMount(() => {
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Session> }) => {
const { theme } = useTheme()
const parts = createMemo(() => parseSessionTitleParts(props.session().title))
return (
<text fg={theme.text}>
<span style={{ bold: true }}>#</span> <span style={{ bold: true }}>{props.session().title}</span>
<span style={{ bold: true }}>#</span>{" "}
<Show when={parts().group} fallback={<span style={{ bold: true }}>{parts().rest}</span>}>
<span style={{ bold: true }}>{parts().group}</span> {parts().rest}
</Show>
</text>
)
}
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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/)
Expand Down
6 changes: 5 additions & 1 deletion packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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] ?? [])
Expand Down Expand Up @@ -92,7 +94,9 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<box flexShrink={0} gap={1} paddingRight={1}>
<box paddingRight={1}>
<text fg={theme.text}>
<b>{session().title}</b>
<Show when={titleParts().group} fallback={<b>{titleParts().rest}</b>}>
<b>{titleParts().group}</b> {titleParts().rest}
</Show>
</text>
<Show when={session().share?.url}>
<text fg={theme.textMuted}>{session().share!.url}</text>
Expand Down
19 changes: 19 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/session-title.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
21 changes: 21 additions & 0 deletions packages/opencode/src/util/locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down