Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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"
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 @@ -403,7 +404,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 @@ -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/)
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