Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
23 changes: 23 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,29 @@ const table = sqliteTable("session", {
})
```

### Reading a nullable column

Two independent absences meet in one expression, and only one of them is
`undefined`. `.get()` yields `undefined` when no row matches — Drizzle normalises
the driver's `null` there — while a nullable column's SQL `NULL` arrives as
`null`. So `row?.some_column` is `T | null | undefined`.

When a caller only asks "is there a value", flatten to `undefined`, and write the
flattening as an annotation rather than an `as` cast:

```ts
// Good — the compiler enforces it; deleting the `?? undefined` is a type error
const boundary: MessageID | undefined = row?.last_checkpoint_message_id ?? undefined

// Bad — the cast removes `null` from the union without converting anything,
// so the declared type is untrue at runtime
return row?.last_checkpoint_message_id as MessageID | undefined
```

Discriminate a possibly-absent value with truthiness or `== null`, never with
`=== undefined` / `!== undefined`. Because `null !== undefined` is `true`, such a
guard typechecks, reads correctly in review, and does nothing.

## Testing

- Avoid mocks as much as possible
Expand Down
22 changes: 22 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/footer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Locale } from "@/util"

/**
* Cell budget for the ephemeral status message in the prompt footer. Sized so
* the message plus the spinner still leaves room for `esc interrupt` and the
* context counter on an 80-column terminal.
*/
export const STATUS_MESSAGE_MAX = 48

/**
* The footer packs the spinner + status message onto the same row as the context
* counter (`52.4K/960K (5%)`). A long server-supplied status string wrapped over
* several lines and squeezed that row until the counter rendered clipped
* (`52.4K/96`). Clamp the message — and flatten any newlines — so a status
* string can never cost the counter its cells.
*/
export function clampStatusMessage(message: string | undefined) {
if (!message) return undefined
const flat = message.replace(/\s+/g, " ").trim()
if (!flat) return undefined
return Locale.truncate(flat, STATUS_MESSAGE_MAX)
}
12 changes: 9 additions & 3 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { assign, expandPlaceholders } from "./part"
import { usePromptStash } from "./stash"
import { clampStatusMessage } from "./footer"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command"
Expand Down Expand Up @@ -1897,11 +1898,13 @@ export function Prompt(props: PromptProps) {
{(() => {
const busyMessage = createMemo(() => {
const s = status()
return s.type === "busy" ? s.message : undefined
return s.type === "busy" ? clampStatusMessage(s.message) : undefined
})
return (
<Show when={busyMessage()}>
<text fg={theme.textMuted}>{busyMessage()}</text>
<text fg={theme.textMuted} wrapMode="none" flexShrink={1}>
{busyMessage()}
</text>
</Show>
)
})()}
Expand Down Expand Up @@ -1992,7 +1995,10 @@ export function Prompt(props: PromptProps) {
<box gap={2} flexDirection="row">
<Show when={usage()}>
{(item) => (
<text fg={theme.textMuted} wrapMode="none">
// flexShrink=0: the context counter is the one number the
// footer must never clip (`52.4K/96` instead of
// `52.4K/960K`); the hints beside it can give way first.
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
{[item().context, item().cost].filter(Boolean).join(" · ")}
</text>
)}
Expand Down
17 changes: 16 additions & 1 deletion packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,21 @@ export function bucketMessages<M extends { agentID?: string | null }>(
return out
}

/**
* A `session.status` event is authoritative for the WHOLE status object.
*
* Solid's store setter merges plain objects into the existing node
* (`mergeStoreNode` only writes `Object.keys(next)`), so writing a bare
* `{ type: "busy" }` — which is what the runner emits at the start of every turn
* (session/run-state.ts:74) — inherits the `message` of whatever status was
* written before it. That latched `/rebuild` outcome text
* (session/prompt.ts:4173) into the following turn's spinner. `reconcile()`
* drops the fields the new status omits, so each status stands alone.
*/
export function nextSessionStatus(status: SessionStatus) {
return reconcile(status)
}

export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
Expand Down Expand Up @@ -452,7 +467,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}

case "session.status": {
setStore("session_status", event.properties.sessionID, event.properties.status)
setStore("session_status", event.properties.sessionID, nextSessionStatus(event.properties.status))
break
}

Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/cli/cmd/tui/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,10 @@ export const dict: Record<string, string> = {
// Session badges
"tui.session.badge.auto": "Auto",

// Context rebuild boundary marker (inserted by /rebuild)
"tui.session.rebuild_boundary.label": "context rebuilt",
"tui.session.rebuild_boundary.detail": "earlier messages summarized",

// Workspace trust
"trust.title": "Accessing workspace:",
"trust.safety_check": "Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open source project, or work from your team). If not, take a moment to review what's in this folder first.",
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/cli/cmd/tui/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,10 @@ export const dict = {
// Session badges
"tui.session.badge.auto": "自动",

// Context rebuild boundary marker (inserted by /rebuild)
"tui.session.rebuild_boundary.label": "上下文已重建",
"tui.session.rebuild_boundary.detail": "较早消息已摘要",

// Workspace trust
"trust.title": "访问工作区:",
"trust.safety_check": "安全确认:这是你自己创建或信任的项目吗?(如你自己的代码、知名开源项目或团队内部项目)。如果不是,请先检查此目录下的内容。",
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/cli/cmd/tui/i18n/zht.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,10 @@ export const dict = {
// Session badges
"tui.session.badge.auto": "自動",

// Context rebuild boundary marker (inserted by /rebuild)
"tui.session.rebuild_boundary.label": "上下文已重建",
"tui.session.rebuild_boundary.detail": "較早訊息已摘要",

// Workspace trust
"trust.title": "存取工作區:",
"trust.safety_check": "安全確認:這是你自己建立或信任的專案嗎?(如你自己的程式碼、知名開源專案或團隊內部專案)。如果不是,請先檢查此目錄下的內容。",
Expand Down
20 changes: 20 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1536,7 +1536,16 @@ function UserMessage(props: {
return parsed ? [parsed] : []
})[0]
})
// A context rebuild (`/rebuild`) inserts a single user message carrying a
// `checkpoint` part plus `synthetic: true` text parts (the rendered context
// and index). Neither renders — `checkpoint` has no PART_MAPPING entry and
// synthetic text is excluded from `text()` above — so the boundary used to be
// completely invisible in the transcript, unlike compaction which at least
// leaves a visible summary message behind. Surface it as a one-line marker
// row so the user can see that a rebuild happened and where.
const rebuildBoundary = createMemo(() => props.parts.some((x) => x.type === "checkpoint"))
const { theme } = useTheme()
const t = useLanguage().t
const [hover, setHover] = createSignal(false)
const queued = createMemo(() => props.pending && props.message.id > props.pending)
const color = createMemo(() => local.agent.color(props.message.agent))
Expand Down Expand Up @@ -1603,6 +1612,17 @@ function UserMessage(props: {
)
}}
</Show>
<Show when={rebuildBoundary()}>
<box id={props.message.id} marginTop={props.index === 0 ? 0 : 1} paddingLeft={2} flexDirection="row" gap={1}>
<text fg={theme.textMuted}>
<span style={{ bg: theme.backgroundElement, fg: theme.primary, bold: true }}>
{" "}
⟲ {t("tui.session.rebuild_boundary.label")}{" "}
</span>
<span style={{ fg: theme.textMuted }}> {t("tui.session.rebuild_boundary.detail")}</span>
</text>
</box>
</Show>
<Show when={text() && !actorNotification()}>
<box
id={props.message.id}
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1386,7 +1386,7 @@ const layer: Layer.Layer<
const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)

provider.models = yield* Effect.promise(async () => {
const next = await models(provider as any, { auth: pluginAuth })
const next = await models(provider, { auth: pluginAuth })
return Object.fromEntries(
Object.entries(next).map(([id, model]) => [
id,
Expand Down
31 changes: 30 additions & 1 deletion packages/opencode/src/session/checkpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1419,7 +1419,36 @@ export const layer: Layer.Layer<
.get(),
),
)
return row?.last_checkpoint_message_id as MessageID | undefined
// Two independent absences meet in this one expression, and only one of
// them is `undefined`:
//
// row is undefined -> no such session row. Drizzle normalises the
// driver's null to undefined here (measured:
// bun:sqlite's .get() returns null, Drizzle
// returns undefined), so this half is honest.
// column is null -> the session exists but no writer has set the
// watermark yet. SQL NULL, faithfully mapped to
// JS null, because the column is nullable
// (session.sql.ts: text().$type<MessageID>()
// with no .notNull()).
//
// So `row?.last_checkpoint_message_id` is `MessageID | null | undefined`.
// Callers only ever ask "is there a boundary to rebuild from", for which
// the last two are the same answer, so flattening to `undefined` is right
// — it also matches Drizzle's own row-level convention.
//
// The flattening is written as an ANNOTATION rather than an `as` cast on
// purpose. A cast would let the union be narrowed by assertion, which is
// how this went wrong before: the previous version claimed
// `as MessageID | undefined` while returning `null`, and a caller that
// then wrote `boundary !== undefined` got a condition that is always true
// — a guard that typechecks, reads correctly, and does nothing. Every
// other caller happened to test truthiness (`!boundary` at prompt.ts:413,
// `watermarkBefore ?` at :1131, `boundaryID ?` in nudgedSinceBoundary) and
// so never noticed. With an annotation, dropping the `?? undefined` is a
// compile error instead of a silent lie.
const boundary: MessageID | undefined = row?.last_checkpoint_message_id ?? undefined
return boundary
})

const isWriterRunning = Effect.fn("SessionCheckpoint.isWriterRunning")(function* (sessionID: SessionID) {
Expand Down
Loading
Loading