Преглед изворни кода

run: make minimal mode more minimal (#31227)

Simon Klee пре 2 месеци
родитељ
комит
07808bea12
39 измењених фајлова са 3108 додато и 929 уклоњено
  1. 1 1
      packages/opencode/src/cli/cmd/run/demo.ts
  2. 11 0
      packages/opencode/src/cli/cmd/run/entry.body.ts
  3. 310 132
      packages/opencode/src/cli/cmd/run/footer.command.tsx
  4. 83 37
      packages/opencode/src/cli/cmd/run/footer.menu.tsx
  5. 9 2
      packages/opencode/src/cli/cmd/run/footer.permission.tsx
  6. 131 30
      packages/opencode/src/cli/cmd/run/footer.prompt.tsx
  7. 2 1
      packages/opencode/src/cli/cmd/run/footer.question.tsx
  8. 8 0
      packages/opencode/src/cli/cmd/run/footer.subagent.tsx
  9. 98 37
      packages/opencode/src/cli/cmd/run/footer.ts
  10. 470 424
      packages/opencode/src/cli/cmd/run/footer.view.tsx
  11. 27 0
      packages/opencode/src/cli/cmd/run/footer.width.ts
  12. 162 0
      packages/opencode/src/cli/cmd/run/prompt.editor.ts
  13. 7 1
      packages/opencode/src/cli/cmd/run/prompt.shared.ts
  14. 72 11
      packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts
  15. 12 10
      packages/opencode/src/cli/cmd/run/runtime.queue.ts
  16. 49 26
      packages/opencode/src/cli/cmd/run/runtime.ts
  17. 14 1
      packages/opencode/src/cli/cmd/run/scrollback.surface.ts
  18. 16 1
      packages/opencode/src/cli/cmd/run/scrollback.writer.tsx
  19. 76 5
      packages/opencode/src/cli/cmd/run/session-replay.ts
  20. 22 48
      packages/opencode/src/cli/cmd/run/splash.ts
  21. 9 12
      packages/opencode/src/cli/cmd/run/stream.transport.ts
  22. 60 29
      packages/opencode/src/cli/cmd/run/subagent-data.ts
  23. 137 50
      packages/opencode/src/cli/cmd/run/theme.ts
  24. 47 0
      packages/opencode/src/cli/cmd/run/turn-summary.ts
  25. 9 1
      packages/opencode/src/cli/cmd/run/types.ts
  26. 2 1
      packages/opencode/src/util/process.ts
  27. 442 34
      packages/opencode/test/cli/run/footer.view.test.tsx
  28. 35 0
      packages/opencode/test/cli/run/footer.width.test.ts
  29. 101 0
      packages/opencode/test/cli/run/prompt.editor.test.ts
  30. 16 0
      packages/opencode/test/cli/run/runtime.queue.test.ts
  31. 238 0
      packages/opencode/test/cli/run/runtime.test.ts
  32. 17 0
      packages/opencode/test/cli/run/scrollback.surface.test.ts
  33. 249 12
      packages/opencode/test/cli/run/session-replay.test.ts
  34. 2 2
      packages/opencode/test/cli/run/stream.transport.test.ts
  35. 98 7
      packages/opencode/test/cli/run/subagent-data.test.ts
  36. 25 0
      packages/opencode/test/cli/run/theme.test.ts
  37. 12 11
      packages/tui/src/component/prompt/index.tsx
  38. 19 2
      packages/tui/src/editor.ts
  39. 10 1
      packages/tui/test/editor.test.ts

+ 1 - 1
packages/opencode/src/cli/cmd/run/demo.ts

@@ -181,7 +181,7 @@ function showSubagent(
     callID: string
     label: string
     description: string
-    status: "running" | "completed" | "error"
+    status: "running" | "completed" | "cancelled" | "error"
     title?: string
     toolCalls?: number
     commits: StreamCommit[]

+ 11 - 0
packages/opencode/src/cli/cmd/run/entry.body.ts

@@ -79,6 +79,13 @@ function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody {
 }
 
 export function entryFlags(commit: StreamCommit): EntryFlags {
+  if (commit.summary) {
+    return {
+      startOnNewLine: true,
+      trailingNewline: false,
+    }
+  }
+
   if (commit.kind === "user") {
     return {
       startOnNewLine: true,
@@ -156,6 +163,10 @@ export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolea
 }
 
 export function entryBody(commit: StreamCommit): RunEntryBody {
+  if (commit.summary) {
+    return RUN_ENTRY_NONE
+  }
+
   const raw = cleanRunText(commit.text)
 
   if (commit.kind === "user") {

+ 310 - 132
packages/opencode/src/cli/cmd/run/footer.command.tsx

@@ -14,6 +14,8 @@ type PanelEntry = RunFooterMenuItem & {
 
 type CommandEntry =
   | (PanelEntry & { action: "model" })
+  | (PanelEntry & { action: "editor" })
+  | (PanelEntry & { action: "skill" })
   | (PanelEntry & { action: "queued" })
   | (PanelEntry & { action: "subagent" })
   | (PanelEntry & { action: "variant.cycle" })
@@ -33,6 +35,10 @@ type VariantEntry = PanelEntry & {
   current: boolean
 }
 
+type SkillEntry = PanelEntry & {
+  name: string
+}
+
 type SubagentEntry = PanelEntry & {
   sessionID: string
   current: boolean
@@ -107,6 +113,10 @@ function subagentStatusLabel(status: FooterSubagentTab["status"]) {
     return "done"
   }
 
+  if (status === "cancelled") {
+    return "cancelled"
+  }
+
   if (status === "error") {
     return "error"
   }
@@ -203,94 +213,122 @@ function PanelShell(props: {
   inputRef: (input: InputRenderable) => void
   onQuery: (query: string) => void
   children: JSX.Element
+  dark?: boolean
+  chrome?: "default" | "minimal"
 }) {
-  return (
-    <box id={props.id} width="100%" flexDirection="column" backgroundColor="transparent" flexShrink={0}>
+  const background = () => (props.dark ? props.theme().shade : props.theme().surface)
+  const minimal = () => props.chrome === "minimal"
+  const content = (
+    <>
+      <box height={1} flexShrink={0} backgroundColor={background()} />
       <box
         width="100%"
-        flexDirection="column"
-        border={["left"]}
-        borderColor={props.theme().highlight}
-        backgroundColor="transparent"
-        customBorderChars={PANEL_BORDER}
+        height={1}
+        paddingLeft={PANEL_PAD}
+        paddingRight={PANEL_PAD}
+        flexDirection="row"
+        gap={1}
         flexShrink={0}
+        backgroundColor={background()}
       >
-        <box height={1} flexShrink={0} backgroundColor={props.theme().surface} />
-        <box
-          width="100%"
-          height={1}
-          paddingLeft={PANEL_PAD}
-          paddingRight={PANEL_PAD}
-          flexDirection="row"
-          gap={1}
-          flexShrink={0}
-          backgroundColor={props.theme().surface}
-        >
-          <text fg={props.theme().text} attributes={TextAttributes.BOLD} wrapMode="none" flexShrink={0}>
-            {props.title}
-          </text>
-          {props.countVisible !== false ? (
-            <text fg={props.theme().muted} wrapMode="none" flexShrink={0}>
-              {countLabel(props.count, props.total, props.query)}
-            </text>
-          ) : null}
-          <box flexGrow={1} flexShrink={1} backgroundColor="transparent" />
-          <text fg={props.theme().muted} wrapMode="none" truncate flexShrink={0}>
-            esc
+        <text fg={props.theme().text} attributes={TextAttributes.BOLD} wrapMode="none" flexShrink={0}>
+          {props.title}
+        </text>
+        {props.countVisible !== false ? (
+          <text fg={props.theme().muted} wrapMode="none" flexShrink={0}>
+            {countLabel(props.count, props.total, props.query)}
           </text>
+        ) : null}
+        <box flexGrow={1} flexShrink={1} backgroundColor="transparent" />
+        <text fg={props.theme().muted} wrapMode="none" truncate flexShrink={0}>
+          esc
+        </text>
+      </box>
+      <box height={1} flexShrink={0} backgroundColor={background()} />
+      <box
+        width="100%"
+        height={1}
+        paddingLeft={PANEL_PAD}
+        paddingRight={PANEL_PAD}
+        flexShrink={0}
+        backgroundColor={background()}
+      >
+        <input
+          width="100%"
+          focusedBackgroundColor={background()}
+          focusedTextColor={props.theme().text}
+          placeholder={props.placeholder}
+          placeholderColor={props.theme().muted}
+          cursorColor={props.theme().highlight}
+          onInput={props.onQuery}
+          ref={(input) => {
+            props.inputRef(input)
+            input.traits = { status: "FILTER" }
+            queueMicrotask(() => {
+              if (!input.isDestroyed) {
+                input.focus()
+              }
+            })
+          }}
+        />
+      </box>
+      <box height={1} flexShrink={0} backgroundColor={background()} />
+      <box width="100%" flexDirection="column" flexShrink={0} backgroundColor={background()}>
+        {props.children}
+      </box>
+    </>
+  )
+  return (
+    <box id={props.id} width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
+      {minimal() ? (
+        <box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
+          {content}
         </box>
-        <box height={1} flexShrink={0} backgroundColor={props.theme().surface} />
+      ) : (
         <box
           width="100%"
-          height={1}
-          paddingLeft={PANEL_PAD}
-          paddingRight={PANEL_PAD}
+          flexDirection="column"
+          border={["left"]}
+          borderColor={props.theme().highlight}
+          backgroundColor="transparent"
+          customBorderChars={PANEL_BORDER}
           flexShrink={0}
-          backgroundColor={props.theme().surface}
         >
-          <input
+          {content}
+        </box>
+      )}
+      {minimal() ? (
+        <box id={`${props.id}-bottom`} width="100%" height={1} border={false} backgroundColor="transparent" flexShrink={0}>
+          <box
             width="100%"
-            focusedBackgroundColor={props.theme().surface}
-            focusedTextColor={props.theme().text}
-            placeholder={props.placeholder}
-            placeholderColor={props.theme().muted}
-            cursorColor={props.theme().highlight}
-            onInput={props.onQuery}
-            ref={(input) => {
-              props.inputRef(input)
-              input.traits = { status: "FILTER" }
-              queueMicrotask(() => {
-                if (!input.isDestroyed) {
-                  input.focus()
-                }
-              })
-            }}
+            height={1}
+            border={["bottom"]}
+            borderColor={background()}
+            backgroundColor="transparent"
+            customBorderChars={HALF_BLOCK_BORDER}
           />
         </box>
-        <box height={1} flexShrink={0} backgroundColor={props.theme().surface} />
-        <box width="100%" flexDirection="column" flexShrink={0} backgroundColor={props.theme().surface}>
-          {props.children}
-        </box>
-      </box>
-      <box
-        id={`${props.id}-bottom`}
-        width="100%"
-        height={1}
-        border={["left"]}
-        borderColor={props.theme().highlight}
-        backgroundColor="transparent"
-        customBorderChars={PANEL_BOTTOM_BORDER}
-        flexShrink={0}
-      >
+      ) : (
         <box
+          id={`${props.id}-bottom`}
           width="100%"
           height={1}
-          border={["bottom"]}
-          borderColor={props.theme().surface}
+          border={["left"]}
+          borderColor={props.theme().highlight}
           backgroundColor="transparent"
-          customBorderChars={HALF_BLOCK_BORDER}
-        />
-      </box>
+          customBorderChars={PANEL_BOTTOM_BORDER}
+          flexShrink={0}
+        >
+          <box
+            width="100%"
+            height={1}
+            border={["bottom"]}
+            borderColor={background()}
+            backgroundColor="transparent"
+            customBorderChars={HALF_BLOCK_BORDER}
+          />
+        </box>
+      )}
     </box>
   )
 }
@@ -304,6 +342,8 @@ export function RunCommandMenuBody(props: {
   variantCycle: string
   onClose: () => void
   onModel: () => void
+  onEditor: () => void
+  onSkill: () => void
   onSubagent: () => void
   onQueued: () => void
   onVariant: () => void
@@ -314,84 +354,116 @@ export function RunCommandMenuBody(props: {
 }) {
   let field: InputRenderable | undefined
   const [query, setQuery] = createSignal("")
+  const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
+  const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length)
   const entries = createMemo<CommandEntry[]>(() => {
-    const builtins = ["new"]
-    return [
+    const builtins = ["editor", "new"]
+    const session: CommandEntry[] = [
+      {
+        action: "editor",
+        category: "Session",
+        display: "Open editor",
+        footer: "/editor",
+        keywords: "editor compose draft external editor",
+      },
+      ...(props.subagents().length > 0
+        ? [
+          {
+            action: "subagent" as const,
+            category: "Session",
+            display: "View subagents",
+            footer: activeSubagentCount() > 0 ? `${activeSubagentCount()} active` : `${props.subagents().length} recent`,
+            keywords: props
+              .subagents()
+              .map((item) => `${item.label} ${item.description} ${item.title ?? ""}`)
+              .join(" "),
+          },
+        ]
+        : []),
+      {
+        action: "slash",
+        category: "Session",
+        name: "new",
+        display: "New session",
+        footer: "/new",
+        keywords: "new session clear",
+      },
+    ]
+    const prompt: CommandEntry[] =
+      props.commands() === undefined || skills().length > 0
+        ? [
+          {
+            action: "skill" as const,
+            category: "Prompt",
+            display: "Skills",
+            footer: "/skills",
+            keywords: `skill skills ${skills()
+              .map((item) => `${item.name} ${item.description ?? ""}`)
+              .join(" ")}`.trim(),
+          },
+        ]
+        : []
+    const agent: CommandEntry[] = [
       {
         action: "model",
-        category: "Suggested",
+        category: "Agent",
         display: "Switch model",
       },
       ...(props.queued().length > 0
         ? [
-            {
-              action: "queued" as const,
-              category: "Suggested",
-              display: "Manage queued prompts",
-              footer: `${props.queued().length} queued`,
-              keywords: props
-                .queued()
-                .map((item) => item.prompt.text)
-                .join(" "),
-            },
-          ]
-        : []),
-      ...(props.subagents().length > 0
-        ? [
-            {
-              action: "subagent" as const,
-              category: "Suggested",
-              display: "View subagents",
-              footer: `${props.subagents().length} active`,
-              keywords: props
-                .subagents()
-                .map((item) => `${item.label} ${item.description} ${item.title ?? ""}`)
-                .join(" "),
-            },
-          ]
+          {
+            action: "queued" as const,
+            category: "Agent",
+            display: "Manage queued prompts",
+            footer: `${props.queued().length} queued`,
+            keywords: props
+              .queued()
+              .map((item) => item.prompt.text)
+              .join(" "),
+          },
+        ]
         : []),
       {
         action: "variant.cycle",
-        category: "Suggested",
+        category: "Agent",
         display: "Variant cycle",
         footer: props.variantCycle,
         keywords: "variant cycle",
       },
       ...(props.variants().length > 0
         ? [
-            {
-              action: "variant.list" as const,
-              category: "Suggested",
-              display: "Switch model variant",
-              keywords: `variant variants ${props.variants().join(" ")}`,
-            },
-          ]
+          {
+            action: "variant.list" as const,
+            category: "Agent",
+            display: "Switch model variant",
+            keywords: `variant variants ${props.variants().join(" ")}`,
+          },
+        ]
         : []),
-      {
-        action: "slash",
-        category: "Session",
-        name: "new",
-        display: "New session",
-        footer: "/new",
-        keywords: "new session clear",
-      },
-      ...(props.commands() ?? [])
-        .filter((item) => item.source !== "skill" && !builtins.includes(item.name))
-        .map(
-          (item) =>
-            ({
-              action: "slash",
-              category: item.source === "mcp" ? "MCP Commands" : "Project Commands",
-              name: item.name,
-              display: item.name,
-              footer: `/${item.name}`,
-              keywords:
-                item.source === "mcp"
-                  ? `/${item.name} ${item.name} mcp ${item.description ?? ""}`
-                  : `/${item.name} ${item.name} ${item.description ?? ""}`,
-            }) satisfies CommandEntry,
-        )
-        .sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)),
+    ]
+    const commands = (props.commands() ?? [])
+      .filter((item) => item.source !== "skill" && !builtins.includes(item.name))
+      .map(
+        (item) =>
+          ({
+            action: "slash",
+            category: item.source === "mcp" ? "MCP Commands" : "Project Commands",
+            name: item.name,
+            display: item.name,
+            footer: `/${item.name}`,
+            keywords:
+              item.source === "mcp"
+                ? `/${item.name} ${item.name} mcp ${item.description ?? ""}`
+                : `/${item.name} ${item.name} ${item.description ?? ""}`,
+          }) satisfies CommandEntry,
+      )
+      .sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display))
+
+    return [
+      ...session,
+      ...prompt,
+      ...agent,
+      ...commands,
       { action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" },
     ]
   })
@@ -403,6 +475,16 @@ export function RunCommandMenuBody(props: {
       return
     }
 
+    if (item.action === "editor") {
+      props.onEditor()
+      return
+    }
+
+    if (item.action === "skill") {
+      props.onSkill()
+      return
+    }
+
     if (item.action === "subagent") {
       props.onSubagent()
       return
@@ -471,6 +553,8 @@ export function RunCommandMenuBody(props: {
         field = input
       }}
       onQuery={setQuery}
+      dark
+      chrome="minimal"
     >
       <RunFooterMenu
         id="run-direct-footer-command-list"
@@ -485,6 +569,8 @@ export function RunCommandMenuBody(props: {
         paddingLeft={PANEL_PAD}
         paddingRight={PANEL_PAD}
         grouped={!query().trim()}
+        background
+        headerColor={props.theme().muted}
       />
     </PanelShell>
   )
@@ -566,6 +652,8 @@ export function RunSubagentSelectBody(props: {
         field = input
       }}
       onQuery={setQuery}
+      dark
+      chrome="minimal"
     >
       <RunFooterMenu
         id="run-direct-footer-subagent-list"
@@ -575,11 +663,12 @@ export function RunSubagentSelectBody(props: {
         offset={menu.offset}
         rows={menu.rows}
         limit={SUBAGENT_LIST_ROWS}
-        empty="No active subagents"
+        empty="No subagents found"
         border={false}
         paddingLeft={PANEL_PAD}
         paddingRight={PANEL_PAD}
         grouped={false}
+        background
       />
     </PanelShell>
   )
@@ -662,6 +751,8 @@ export function RunQueuedPromptSelectBody(props: {
         field = input
       }}
       onQuery={setQuery}
+      dark
+      chrome="minimal"
     >
       <RunFooterMenu
         id="run-direct-footer-queued-list"
@@ -676,6 +767,86 @@ export function RunQueuedPromptSelectBody(props: {
         paddingLeft={PANEL_PAD}
         paddingRight={PANEL_PAD}
         grouped={false}
+        background
+      />
+    </PanelShell>
+  )
+}
+
+export function RunSkillSelectBody(props: {
+  theme: Accessor<RunFooterTheme>
+  commands: Accessor<RunCommand[] | undefined>
+  onClose: () => void
+  onSelect: (name: string) => void
+}) {
+  let field: InputRenderable | undefined
+  const [query, setQuery] = createSignal("")
+  const entries = createMemo<SkillEntry[]>(() =>
+    (props.commands() ?? [])
+      .filter((item) => item.source === "skill")
+      .map((item) => ({
+        category: "",
+        display: item.name,
+        description: item.description?.replace(/\s+/g, " ").trim() || undefined,
+        keywords: `skill ${item.name} ${item.description ?? ""}`,
+        name: item.name,
+      }))
+      .sort((a, b) => a.display.localeCompare(b.display)),
+  )
+  const items = createMemo<SkillEntry[]>(() => match(query(), entries()))
+  const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
+  const select = () => {
+    const item = items()[menu.selected()]
+    if (!item) {
+      return
+    }
+
+    props.onSelect(item.name)
+  }
+
+  createEffect(() => {
+    query()
+    menu.reset()
+  })
+
+  useKeyboard((event) => {
+    if (event.defaultPrevented) {
+      return
+    }
+
+    handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
+  })
+
+  return (
+    <PanelShell
+      id="run-direct-footer-skill-panel"
+      title="Skills"
+      query={query()}
+      count={items().length}
+      total={entries().length}
+      placeholder="Search"
+      theme={props.theme}
+      inputRef={(input) => {
+        field = input
+      }}
+      onQuery={setQuery}
+      dark
+      chrome="minimal"
+    >
+      <RunFooterMenu
+        id="run-direct-footer-skill-list"
+        theme={props.theme}
+        items={items}
+        selected={menu.selected}
+        offset={menu.offset}
+        rows={() => PANEL_LIST_ROWS}
+        limit={PANEL_LIST_ROWS}
+        empty={props.commands() ? "No skills found" : "Skills loading"}
+        border={false}
+        paddingLeft={PANEL_PAD}
+        paddingRight={PANEL_PAD}
+        grouped={false}
+        background
       />
     </PanelShell>
   )
@@ -759,6 +930,8 @@ export function RunVariantSelectBody(props: {
         field = input
       }}
       onQuery={setQuery}
+      dark
+      chrome="minimal"
     >
       <RunFooterMenu
         id="run-direct-footer-variant-list"
@@ -773,6 +946,7 @@ export function RunVariantSelectBody(props: {
         paddingLeft={PANEL_PAD}
         paddingRight={PANEL_PAD}
         grouped={false}
+        background
       />
     </PanelShell>
   )
@@ -879,6 +1053,8 @@ export function RunModelSelectBody(props: {
         field = input
       }}
       onQuery={setQuery}
+      dark
+      chrome="minimal"
     >
       <RunFooterMenu
         id="run-direct-footer-model-list"
@@ -893,6 +1069,8 @@ export function RunModelSelectBody(props: {
         paddingLeft={PANEL_PAD}
         paddingRight={PANEL_PAD}
         grouped={!query().trim()}
+        background
+        headerColor={props.theme().muted}
       />
     </PanelShell>
   )

+ 83 - 37
packages/opencode/src/cli/cmd/run/footer.menu.tsx

@@ -1,7 +1,9 @@
 /** @jsxImportSource @opentui/solid */
-import { TextAttributes } from "@opentui/core"
+import { TextAttributes, type ColorInput } from "@opentui/core"
+import { useTerminalDimensions } from "@opentui/solid"
 import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
 import { transparent, type RunFooterTheme } from "./theme"
+import * as Locale from "@/util/locale"
 
 export const FOOTER_MENU_ROWS = 8
 
@@ -125,7 +127,10 @@ export function RunFooterMenu(props: {
   paddingLeft?: number
   paddingRight?: number
   grouped?: boolean
+  background?: boolean
+  headerColor?: ColorInput
 }) {
+  const term = useTerminalDimensions()
   const limit = () => props.limit ?? FOOTER_MENU_ROWS
   const border = () => props.border ?? true
   const [groupOffset, setGroupOffset] = createSignal(0)
@@ -203,16 +208,36 @@ export function RunFooterMenu(props: {
 
     return " ".repeat(Math.max(1, descriptionColumn() - Bun.stringWidth(item.display)))
   }
+  const descriptionText = (item: RunFooterMenuItem) => {
+    if (!item.description) {
+      return
+    }
+
+    const footerWidth = item.footer ? Bun.stringWidth(item.footer) + 1 : 0
+    const available =
+      term().width -
+      (border() ? 1 : 0) -
+      (props.paddingLeft ?? 1) -
+      (props.paddingRight ?? 0) -
+      descriptionColumn() -
+      footerWidth -
+      4
+    return Locale.truncate(item.description, Math.max(12, available))
+  }
   return (
     <box
       id={props.id ?? "run-direct-footer-menu"}
       width="100%"
       height={props.rows()}
-      backgroundColor={transparent}
+      backgroundColor={props.background ? props.theme().shade : transparent}
       flexDirection="column"
     >
       {rows().length === 0 ? (
-        <box paddingRight={0} flexDirection="row" backgroundColor={transparent}>
+        <box
+          paddingRight={0}
+          flexDirection="row"
+          backgroundColor={props.background ? props.theme().shade : transparent}
+        >
           {border() ? (
             <text fg={props.theme().border} wrapMode="none">
@@ -223,7 +248,7 @@ export function RunFooterMenu(props: {
             flexShrink={1}
             paddingLeft={props.paddingLeft ?? 1}
             paddingRight={props.paddingRight ?? 0}
-            backgroundColor={props.theme().surface}
+            backgroundColor={props.background ? props.theme().shade : transparent}
           >
             <text fg={props.theme().muted} wrapMode="none" truncate>
               {props.empty ?? "No matching items"}
@@ -239,7 +264,7 @@ export function RunFooterMenu(props: {
           if (row.type === "header") {
             return (
               <box paddingLeft={props.paddingLeft ?? 1} paddingRight={props.paddingRight ?? 1}>
-                <text fg={props.theme().highlight} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
+                <text fg={props.headerColor ?? props.theme().highlight} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
                   {row.label}
                 </text>
               </box>
@@ -247,54 +272,75 @@ export function RunFooterMenu(props: {
           }
 
           const active = () => row.index === props.selected()
-          const inset = () => (active() ? 1 : 0)
+          const background = () =>
+            active()
+              ? props.background
+                ? props.theme().selected
+                : props.theme().shade
+              : props.background
+                ? props.theme().shade
+                : transparent
           return (
-            <box paddingRight={0} flexDirection="row" backgroundColor={transparent}>
+            <box
+              paddingRight={0}
+              flexDirection="row"
+              backgroundColor={background()}
+            >
               {border() ? (
-                <text fg={active() ? props.theme().highlight : props.theme().border} wrapMode="none">
-                  ┃
+                <text fg={props.theme().highlight} bg={background()} wrapMode="none">
+                  {active() ? "▌" : " "}
                 </text>
               ) : undefined}
               <box
                 flexGrow={1}
                 flexShrink={1}
-                paddingLeft={inset()}
-                paddingRight={inset()}
-                backgroundColor={props.theme().surface}
+                paddingLeft={props.paddingLeft ?? 1}
+                paddingRight={props.paddingRight ?? 0}
+                backgroundColor={background()}
               >
-                <box
-                  flexGrow={1}
-                  flexShrink={1}
-                  paddingLeft={Math.max(0, (props.paddingLeft ?? 1) - inset())}
-                  paddingRight={Math.max(0, (props.paddingRight ?? 0) - inset())}
-                  backgroundColor={active() ? props.theme().highlight : props.theme().surface}
-                >
-                  <box width="100%" flexDirection="row" justifyContent="space-between" gap={1}>
+                <box width="100%" flexDirection="row" justifyContent="space-between" gap={1}>
+                  <box flexDirection="row" gap={0} flexGrow={1} flexShrink={1}>
                     <text
-                      fg={active() ? props.theme().surface : props.theme().text}
+                      fg={active() ? props.theme().selectedText : props.theme().text}
+                      attributes={active() ? TextAttributes.BOLD : undefined}
                       wrapMode="none"
                       truncate
-                      flexGrow={1}
+                      flexShrink={0}
                     >
                       {row.item.display}
-                      {row.item.description ? (
-                        <span style={{ fg: active() ? props.theme().surface : props.theme().muted }}>
-                          {descriptionPad(row.item)}
-                          {row.item.description}
-                        </span>
-                      ) : undefined}
                     </text>
-                    {row.item.footer ? (
-                      <text
-                        fg={active() ? props.theme().surface : props.theme().muted}
-                        wrapMode="none"
-                        truncate
-                        flexShrink={0}
-                      >
-                        {row.item.footer}
-                      </text>
+                    {row.item.description ? (
+                      <>
+                        <text
+                          fg={active() ? props.theme().selectedText : props.theme().muted}
+                          wrapMode="none"
+                          flexShrink={0}
+                        >
+                          {descriptionPad(row.item)}
+                        </text>
+                        <text
+                          fg={active() ? props.theme().selectedText : props.theme().muted}
+                          wrapMode="none"
+                          truncate
+                          flexGrow={1}
+                          flexShrink={1}
+                        >
+                          {descriptionText(row.item)}
+                        </text>
+                      </>
                     ) : undefined}
                   </box>
+                  {row.item.footer ? (
+                    <text
+                      fg={active() ? props.theme().selectedText : props.theme().muted}
+                      attributes={active() ? TextAttributes.BOLD : undefined}
+                      wrapMode="none"
+                      truncate
+                      flexShrink={0}
+                    >
+                      {row.item.footer}
+                    </text>
+                  ) : undefined}
                 </box>
               </box>
             </box>

+ 9 - 2
packages/opencode/src/cli/cmd/run/footer.permission.tsx

@@ -29,6 +29,7 @@ import {
   permissionShift,
   type PermissionOption,
 } from "./permission.shared"
+import { footerWidthPolicy } from "./footer.width"
 import { toolFiletype } from "./tool"
 import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
 import type { PermissionReply, RunDiffStyle } from "./types"
@@ -140,7 +141,7 @@ export function RunPermissionBody(props: {
   const [state, setState] = createSignal(createPermissionBodyState(props.request.id))
   const info = createMemo(() => permissionInfo(props.request))
   const ft = createMemo(() => toolFiletype(info().file))
-  const narrow = createMemo(() => dims().width < 80)
+  const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
   const opts = createMemo(() => permissionOptions(state().stage))
   const busy = createMemo(() => state().submitting)
   const title = createMemo(() => {
@@ -257,7 +258,13 @@ export function RunPermissionBody(props: {
   })
 
   return (
-    <box id="run-direct-footer-permission-body" width="100%" height="100%" flexDirection="column">
+    <box
+      id="run-direct-footer-permission-body"
+      width="100%"
+      height="100%"
+      flexDirection="column"
+      backgroundColor={props.theme.surface}
+    >
       <box
         id="run-direct-footer-permission-head"
         flexDirection="column"

+ 131 - 30
packages/opencode/src/cli/cmd/run/footer.prompt.tsx

@@ -1,13 +1,14 @@
-// Prompt textarea component and its state machine for direct interactive mode.
+// Prompt composer and its state machine for direct interactive mode.
 //
 // createPromptState() wires keymap command layers, history navigation, and
 // `@` autocomplete for files, subagents, and MCP resources.
-// It produces a PromptState that RunPromptBody renders as an OpenTUI textarea,
-// while the footer view renders the current menu state below it.
+// It produces a PromptState that RunPromptBody renders as a slim single-line
+// composer while the footer view renders any active menus below it.
 /** @jsxImportSource @opentui/solid */
 import { pathToFileURL } from "bun"
-import { StyledText, bg, fg, type KeyEvent, type TextareaRenderable } from "@opentui/core"
+import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
 import { useRenderer } from "@opentui/solid"
+import { normalizePromptContent } from "@opencode-ai/tui/editor"
 import fuzzysort from "fuzzysort"
 import path from "path"
 import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
@@ -23,6 +24,7 @@ import {
   pushPromptHistory,
 } from "./prompt.shared"
 import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
+import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
 import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
 import type { RunFooterTheme } from "./theme"
 import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource, RunTuiConfig } from "./types"
@@ -34,13 +36,6 @@ export const TEXTAREA_MIN_ROWS = 1
 export const TEXTAREA_MAX_ROWS = 6
 export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 + AUTOCOMPLETE_BOTTOM_ROWS
 
-export const HINT_BREAKPOINTS = {
-  send: 50,
-  newline: 66,
-  history: 80,
-  command: 95,
-}
-
 type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
 
 type Auto = RunFooterMenuItem & {
@@ -53,6 +48,7 @@ type Auto = RunFooterMenuItem & {
 type SlashOption = RunFooterMenuItem & {
   kind: "slash"
   name: string
+  action?: "skill-menu" | "editor"
 }
 
 type PromptOption = Auto | SlashOption
@@ -75,9 +71,11 @@ type PromptInput = {
   onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
   onCycle: () => void
   onInterrupt: () => boolean
+  onEditorOpen: (input: { value: string }) => Promise<string | undefined>
   onInputClear: () => void
   onExitRequest?: () => boolean
   onExit: () => void
+  onSkillMenu: () => void
   onRows: (rows: number) => void
   onStatus: (text: string) => void
 }
@@ -93,6 +91,7 @@ export type PromptState = {
   requestExit: () => boolean
   onSubmit: () => void
   submitText: (text: string) => void
+  openEditor: (input?: { value?: string }) => Promise<void>
   onKeyDown: (event: KeyEvent) => void
   onContentChange: () => void
   replaceDraft: (text: string) => void
@@ -109,6 +108,7 @@ function clonePrompt(prompt: RunPrompt): RunPrompt {
     text: prompt.text,
     parts: structuredClone(prompt.parts),
     ...(prompt.mode ? { mode: prompt.mode } : {}),
+    ...(prompt.command ? { command: prompt.command } : {}),
   }
 }
 
@@ -182,17 +182,25 @@ function parseSlashCommand(text: string, commands: RunCommand[] | undefined) {
   return { type: "command" as const, command: { name: head.name, arguments: head.arguments } }
 }
 
-export function hintFlags(width: number) {
+function selectedCommand(text: string, command: RunPrompt["command"]) {
+  if (!command) {
+    return
+  }
+
+  const head = slashHead(text)
+  if (!head || head.name !== command.name) {
+    return
+  }
+
   return {
-    send: width >= HINT_BREAKPOINTS.send,
-    newline: width >= HINT_BREAKPOINTS.newline,
-    history: width >= HINT_BREAKPOINTS.history,
-    command: width >= HINT_BREAKPOINTS.command,
+    name: command.name,
+    arguments: head.arguments,
   }
 }
 
 export function RunPromptBody(props: {
   theme: () => RunFooterTheme
+  background: () => ColorInput
   placeholder: () => StyledText | string
   onSubmit: () => void
   onKeyDown: (event: KeyEvent) => void
@@ -226,7 +234,7 @@ export function RunPromptBody(props: {
 
           props.onContentChange()
         })
-        .catch(() => {})
+        .catch(() => { })
     }, 0)
   }
 
@@ -243,7 +251,7 @@ export function RunPromptBody(props: {
 
   return (
     <box id="run-direct-footer-prompt" width="100%">
-      <box id="run-direct-footer-input-shell" paddingTop={1} paddingLeft={2} paddingRight={2}>
+      <box id="run-direct-footer-input-shell" paddingTop={1} paddingBottom={1} paddingRight={2}>
         <textarea
           id="run-direct-footer-composer"
           width="100%"
@@ -254,8 +262,8 @@ export function RunPromptBody(props: {
           placeholderColor={props.theme().muted}
           textColor={props.theme().text}
           focusedTextColor={props.theme().text}
-          backgroundColor={props.theme().surface}
-          focusedBackgroundColor={props.theme().surface}
+          backgroundColor={props.background()}
+          focusedBackgroundColor={props.background()}
           cursorColor={props.theme().text}
           onSubmit={props.onSubmit}
           onKeyDown={props.onKeyDown}
@@ -276,16 +284,14 @@ export function createPromptState(input: PromptInput): PromptState {
   const [shell, setShell] = createSignal(false)
   const placeholder = createMemo(() => {
     if (shell()) {
-      return new StyledText([bg(input.theme().surface)(fg(input.theme().muted)('Run a command... "git status"'))])
+      return new StyledText([fg(input.theme().muted)('Run a command... "git status"')])
     }
 
     if (!input.state().first) {
       return ""
     }
 
-    return new StyledText([
-      bg(input.theme().surface)(fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')),
-    ])
+    return new StyledText([fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')])
   })
 
   let history = createPromptHistory(input.history)
@@ -412,13 +418,40 @@ export function createPromptState(input: PromptInput): PromptState {
     { initialValue: [] as Auto[] },
   )
   const mentionOptions = createMemo(() => [...agents(), ...files(), ...resources()])
+  const skillCommands = createMemo(() => (input.commands() ?? []).filter((item) => item.source === "skill"))
+  const hasSkillsCommand = createMemo(() =>
+    (input.commands() ?? []).some((item) => item.source !== "skill" && item.name === "skills"),
+  )
   const slashOptions = createMemo<SlashOption[]>(() => {
     const builtins = [
+      {
+        kind: "slash",
+        action: "editor" as const,
+        name: "editor",
+        display: "/editor",
+        description: "compose in your external editor",
+      } satisfies SlashOption,
       { kind: "slash", name: "new", display: "/new", description: "start a new session" } satisfies SlashOption,
-      { kind: "slash", name: "exit", display: "/exit", description: "close direct mode" } satisfies SlashOption,
+      { kind: "slash", name: "exit", display: "/exit", description: "close OpenCode" } satisfies SlashOption,
     ]
     const hidden = new Set(builtins.map((item) => item.name))
+    const showSkillMenu = !shell() && skillCommands().length > 0 && !hasSkillsCommand()
+    if (showSkillMenu) {
+      hidden.add("skills")
+    }
+
     return [
+      ...(showSkillMenu
+        ? [
+            {
+              kind: "slash",
+              action: "skill-menu" as const,
+              name: "skills",
+              display: "/skills",
+              description: "browse available skills",
+            } satisfies SlashOption,
+          ]
+        : []),
       ...(input.commands() ?? [])
         .filter((item) => item.source !== "skill" && !hidden.has(item.name))
         .map(
@@ -461,7 +494,7 @@ export function createPromptState(input: PromptInput): PromptState {
       return
     }
 
-    input.onRows(clamp(area.virtualLineCount || 1) + popup())
+    input.onRows(clamp(Math.max(area.lineCount, area.virtualLineCount)) + popup())
   }
 
   const scheduleRows = () => {
@@ -695,6 +728,7 @@ export function createPromptState(input: PromptInput): PromptState {
     }
 
     syncParts()
+    const command = shell() ? undefined : selectedCommand(area.plainText, draft.command)
     draft = shell()
       ? {
           text: area.plainText,
@@ -704,6 +738,7 @@ export function createPromptState(input: PromptInput): PromptState {
       : {
           text: area.plainText,
           parts: structuredClone(parts),
+          ...(command ? { command } : {}),
         }
   }
 
@@ -783,6 +818,33 @@ export function createPromptState(input: PromptInput): PromptState {
     area.focus()
   }
 
+  const openEditor = async (inputValue?: { value?: string }) => {
+    input.onInputClear()
+    syncDraft()
+    hide()
+
+    const current = clonePrompt(draft)
+    try {
+      const content = await input.onEditorOpen({
+        value: inputValue?.value ?? current.text,
+      })
+      if (content === undefined) {
+        return
+      }
+      const normalized = normalizePromptContent(content)
+
+      restore({
+        text: normalized,
+        parts: realignEditorPromptParts(normalized, current.parts),
+        ...(current.mode ? { mode: current.mode } : {}),
+        ...(current.command ? { command: current.command } : {}),
+      })
+    } catch {
+      restore(current)
+      input.onStatus("failed to open editor")
+    }
+  }
+
   const select = (item?: PromptOption) => {
     const next = item ?? options()[menu.selected()]
     if (!next || !area || area.isDestroyed) {
@@ -790,6 +852,19 @@ export function createPromptState(input: PromptInput): PromptState {
     }
 
     if (next.kind === "slash") {
+      if (next.action === "editor") {
+        void openEditor({
+          value: resolveEditorSlashValue(area.plainText),
+        })
+        return
+      }
+
+      if (next.action === "skill-menu") {
+        cancelAutocomplete()
+        input.onSkillMenu()
+        return
+      }
+
       const cursor = area.cursorOffset
       const head = slashHead(area.plainText)
       const local = !shell() && (next.name === "new" || next.name === "exit")
@@ -898,6 +973,7 @@ export function createPromptState(input: PromptInput): PromptState {
   const baseBindingsEnabled = () => {
     const current = input.view()
     if (current === "command") return false
+    if (current === "skill") return false
     if (current === "model") return false
     if (current === "variant") return false
     if (current === "queued-menu") return false
@@ -939,6 +1015,22 @@ export function createPromptState(input: PromptInput): PromptState {
     bindings: input.tuiConfig.keybinds.get("session.interrupt"),
   }))
 
+  useBindings(() => ({
+    mode: OPENCODE_BASE_MODE,
+    enabled: input.prompt() && !visible(),
+    commands: [
+      {
+        name: "prompt.editor",
+        title: "Open editor",
+        category: "Prompt",
+        run() {
+          void openEditor()
+        },
+      },
+    ],
+    bindings: input.tuiConfig.keybinds.get("prompt.editor"),
+  }))
+
   useBindings(() => ({
     mode: OPENCODE_BASE_MODE,
     enabled: input.prompt() && !visible(),
@@ -1068,7 +1160,11 @@ export function createPromptState(input: PromptInput): PromptState {
     ]),
   }))
 
-  const onKeyDown = (_event: KeyEvent) => {}
+  const onKeyDown = (event: KeyEvent) => {
+    if (input.state().phase === "idle" && event.name.toLowerCase() === "escape") {
+      input.onInputClear()
+    }
+  }
 
   const submitPrompt = (next: RunPrompt) => {
     if (!area || area.isDestroyed) {
@@ -1089,19 +1185,22 @@ export function createPromptState(input: PromptInput): PromptState {
       return
     }
 
-    if (next.mode !== "shell" && isExitCommand(next.text)) {
+    const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
+    if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
       input.onExit()
       return
     }
 
     const parsed =
-      next.mode === "shell" || isNewCommand(next.text) ? undefined : parseSlashCommand(next.text, input.commands())
+      command || next.mode === "shell" || isNewCommand(next.text)
+        ? undefined
+        : parseSlashCommand(next.text, input.commands())
     if (parsed?.type === "pending") {
       input.onStatus("loading commands")
       return
     }
 
-    const submit = parsed?.type === "command" ? { ...next, command: parsed.command } : next
+    const submit = command ? { ...next, command } : parsed?.type === "command" ? { ...next, command: parsed.command } : next
     const shellMode = next.mode === "shell"
 
     resetDraft()
@@ -1194,8 +1293,10 @@ export function createPromptState(input: PromptInput): PromptState {
     requestExit,
     onSubmit,
     submitText,
+    openEditor,
     onKeyDown,
     onContentChange: () => {
+      input.onInputClear()
       syncDraft()
       refresh()
       scheduleRows()

+ 2 - 1
packages/opencode/src/cli/cmd/run/footer.question.tsx

@@ -40,6 +40,7 @@ import {
   questionTabs,
   questionTotal,
 } from "./question.shared"
+import { footerWidthPolicy } from "./footer.width"
 import type { RunFooterTheme } from "./theme"
 import type { QuestionReject, QuestionReply } from "./types"
 
@@ -58,7 +59,7 @@ export function RunQuestionBody(props: {
   const other = createMemo(() => questionOther(props.request, state()))
   const picked = createMemo(() => questionPicked(state()))
   const disabled = createMemo(() => state().submitting)
-  const narrow = createMemo(() => dims().width < 80)
+  const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
   const verb = createMemo(() => {
     if (confirm()) {
       return "submit"

+ 8 - 0
packages/opencode/src/cli/cmd/run/footer.subagent.tsx

@@ -15,6 +15,10 @@ function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"])
     return theme.highlight
   }
 
+  if (status === "cancelled") {
+    return theme.muted
+  }
+
   if (status === "error") {
     return theme.error
   }
@@ -27,6 +31,10 @@ function statusIcon(status: FooterSubagentTab["status"]) {
     return "●"
   }
 
+  if (status === "cancelled") {
+    return "○"
+  }
+
   if (status === "error") {
     return "◍"
   }

+ 98 - 37
packages/opencode/src/cli/cmd/run/footer.ts

@@ -29,7 +29,7 @@ import type { Keymap } from "@opentui/keymap"
 import { render } from "@opentui/solid"
 import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
 import { createStore, reconcile } from "solid-js/store"
-import { OpencodeKeymapProvider, formatKeyBindings } from "@opencode-ai/tui/keymap"
+import { OpencodeKeymapProvider } from "@opencode-ai/tui/keymap"
 import { withRunSpan } from "./otel"
 import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
 import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
@@ -37,6 +37,7 @@ import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
 import { RunFooterView } from "./footer.view"
 import { RunScrollbackStream } from "./scrollback.surface"
 import { RUN_THEME_FALLBACK, resolveRunTheme, type RunTheme } from "./theme"
+import { modelInfo } from "./variant.shared"
 import type {
   FooterApi,
   FooterEvent,
@@ -94,6 +95,7 @@ type RunFooterOptions = {
   onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
   onInterrupt?: () => void
   onBackground?: () => void
+  onEditorOpen: (input: { value: string }) => Promise<string | undefined>
   onExit?: () => void
   onSubagentSelect?: (sessionID: string | undefined) => void
   treeSitterClient?: TreeSitterClient
@@ -102,10 +104,11 @@ type RunFooterOptions = {
 const PERMISSION_ROWS = 12
 const QUESTION_ROWS = 14
 const COMMAND_ROWS = RUN_COMMAND_PANEL_ROWS
+const SKILL_ROWS = RUN_COMMAND_PANEL_ROWS
 const SUBAGENT_ROWS = RUN_SUBAGENT_PANEL_ROWS
 const MODEL_ROWS = RUN_COMMAND_PANEL_ROWS
 const VARIANT_ROWS = RUN_COMMAND_PANEL_ROWS
-const AUTOCOMPLETE_COMPACT_ROWS = 2
+const NOTICE_DURATION = 3000
 const THEME_REFRESH_DELAYS = [1000, 1000] as const
 
 function createEmptySubagentState(): FooterSubagentState {
@@ -135,6 +138,8 @@ function eventPatch(next: FooterEvent): FooterPatch | undefined {
       phase: "running",
       status: "sending prompt",
       queue: next.queue,
+      interrupt: 0,
+      exit: 0,
     }
   }
 
@@ -153,10 +158,6 @@ function eventPatch(next: FooterEvent): FooterPatch | undefined {
     }
   }
 
-  if (next.type === "turn.duration") {
-    return { duration: next.duration }
-  }
-
   if (next.type === "stream.patch") {
     return next.patch
   }
@@ -207,6 +208,9 @@ export class RunFooter implements FooterApi {
   private autocomplete = false
   private interruptTimeout: NodeJS.Timeout | undefined
   private exitTimeout: NodeJS.Timeout | undefined
+  private noticeTimeout: NodeJS.Timeout | undefined
+  private noticeRestoreStatus = ""
+  private statusVersion = 0
   private requestExitHandler: (() => boolean) | undefined
   private scrollback: RunScrollbackStream
   private themes: RunTheme[]
@@ -327,6 +331,7 @@ export class RunFooter implements FooterApi {
               onCycle: footer.handleCycle,
               onInterrupt: footer.handleInterrupt,
               onBackground: options.onBackground,
+              onEditorOpen: options.onEditorOpen,
               onInputClear: footer.handleInputClear,
               onExitRequest: footer.handleExit,
               onRequestExit: footer.setRequestExitHandler,
@@ -384,6 +389,23 @@ export class RunFooter implements FooterApi {
   }
 
   public event(next: FooterEvent): void {
+    if (next.type === "turn.duration") {
+      const current = this.currentModel()
+      this.flush()
+      this.flushing = this.flushing
+        .then(() =>
+          this.scrollback.writeTurnSummary({
+            agent: this.options.agentLabel,
+            model: current ? modelInfo(this.providers(), current).model : this.state().model,
+            duration: next.duration,
+          }),
+        )
+        .catch((error) => {
+          this.flushError = error
+        })
+      return
+    }
+
     if (next.type === "catalog") {
       if (this.isGone) {
         return
@@ -427,6 +449,13 @@ export class RunFooter implements FooterApi {
 
     const patch = eventPatch(next)
     if (patch) {
+      if (typeof patch.status === "string") {
+        this.clearNoticeTimer()
+      }
+      if (next.type === "turn.send") {
+        this.clearInterruptTimer()
+        this.clearExitTimer()
+      }
       this.patch(patch)
       return
     }
@@ -452,6 +481,9 @@ export class RunFooter implements FooterApi {
     }
 
     const prev = this.state()
+    if (typeof next.status === "string") {
+      this.statusVersion++
+    }
     const state = {
       phase: next.phase ?? prev.phase,
       status: typeof next.status === "string" ? next.status : prev.status,
@@ -626,7 +658,31 @@ export class RunFooter implements FooterApi {
   }
 
   private setStatus = (status: string): void => {
+    this.setNotice(status)
+  }
+
+  private setNotice(status: string): void {
+    const restore = this.noticeTimeout ? this.noticeRestoreStatus : this.state().status
+    this.clearNoticeTimer(false)
     this.patch({ status })
+    if (!status) {
+      this.noticeRestoreStatus = ""
+      return
+    }
+
+    this.noticeRestoreStatus = restore
+    const version = this.statusVersion
+    this.noticeTimeout = setTimeout(() => {
+      this.noticeTimeout = undefined
+      if (this.isGone || version !== this.statusVersion) {
+        this.noticeRestoreStatus = ""
+        return
+      }
+
+      const next = this.noticeRestoreStatus
+      this.noticeRestoreStatus = ""
+      this.patch({ status: next })
+    }, NOTICE_DURATION)
   }
 
   private setRequestExitHandler = (fn?: () => boolean): void => {
@@ -652,8 +708,6 @@ export class RunFooter implements FooterApi {
   // get fixed extra rows; the prompt view scales with textarea line count.
   private applyHeight(): void {
     const type = this.view().type
-    const compact = this.promptRoute.type === "composer" && this.autocomplete ? AUTOCOMPLETE_COMPACT_ROWS : 0
-    const base = this.base - compact
     const height =
       type === "permission"
         ? this.base + PERMISSION_ROWS
@@ -661,9 +715,11 @@ export class RunFooter implements FooterApi {
           ? this.base + QUESTION_ROWS
           : this.promptRoute.type === "command"
             ? 1 + COMMAND_ROWS
-            : this.promptRoute.type === "model"
-              ? 1 + MODEL_ROWS
-              : this.promptRoute.type === "variant"
+            : this.promptRoute.type === "skill"
+              ? 1 + SKILL_ROWS
+              : this.promptRoute.type === "model"
+                ? 1 + MODEL_ROWS
+                : this.promptRoute.type === "variant"
                 ? 1 + VARIANT_ROWS
                 : this.promptRoute.type === "queued-menu"
                   ? 1 + this.subagentMenuRows
@@ -671,7 +727,7 @@ export class RunFooter implements FooterApi {
                     ? 1 + this.subagentMenuRows
                     : this.promptRoute.type === "subagent"
                       ? this.base + SUBAGENT_INSPECTOR_ROWS
-                      : Math.max(base + TEXTAREA_MIN_ROWS, Math.min(base + PROMPT_MAX_ROWS, base + this.rows))
+                      : this.base + Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, this.rows))
 
     if (height !== this.renderer.footerHeight) {
       this.renderer.footerHeight = height
@@ -713,7 +769,7 @@ export class RunFooter implements FooterApi {
     }
 
     if (this.prompts.size === 0) {
-      this.patch({ status: "input queue unavailable" })
+      this.setNotice("input queue unavailable")
       return false
     }
 
@@ -751,13 +807,11 @@ export class RunFooter implements FooterApi {
   private handleCycle = (): void => {
     const result = this.options.onCycleVariant?.()
     if (!result) {
-      this.patch({ status: "no variants available" })
+      this.setNotice("no variants available")
       return
     }
 
-    const patch: FooterPatch = {
-      status: result.status ?? "variant updated",
-    }
+    const patch: FooterPatch = {}
 
     if ("variants" in result) {
       this.setVariants(result.variants ?? [])
@@ -772,6 +826,7 @@ export class RunFooter implements FooterApi {
     }
 
     this.patch(patch)
+    this.setNotice(result.status ?? "variant updated")
   }
 
   private handleModelSelect = (model: NonNullable<RunInput["model"]>): void => {
@@ -811,13 +866,12 @@ export class RunFooter implements FooterApi {
           patch.model = result.modelLabel
         }
 
-        if (result.status) {
-          patch.status = result.status
-        }
-
-        if (patch.model || patch.status) {
+        if (patch.model) {
           this.patch(patch)
         }
+        if (result.status) {
+          this.setNotice(result.status)
+        }
       })
       .catch(() => {})
   }
@@ -853,13 +907,12 @@ export class RunFooter implements FooterApi {
           patch.model = result.modelLabel
         }
 
-        if (result.status) {
-          patch.status = result.status
-        }
-
-        if (patch.model || patch.status) {
+        if (patch.model) {
           this.patch(patch)
         }
+        if (result.status) {
+          this.setNotice(result.status)
+        }
       })
       .catch(() => {})
   }
@@ -873,6 +926,21 @@ export class RunFooter implements FooterApi {
     this.interruptTimeout = undefined
   }
 
+  private clearNoticeTimer(reset = true): void {
+    if (!this.noticeTimeout) {
+      if (reset) {
+        this.noticeRestoreStatus = ""
+      }
+      return
+    }
+
+    clearTimeout(this.noticeTimeout)
+    this.noticeTimeout = undefined
+    if (reset) {
+      this.noticeRestoreStatus = ""
+    }
+  }
+
   private armInterruptTimer(): void {
     this.clearInterruptTimer()
     this.interruptTimeout = setTimeout(() => {
@@ -885,13 +953,6 @@ export class RunFooter implements FooterApi {
     }, 5000)
   }
 
-  private interruptHint(): string {
-    const bindings = this.options.keymap
-      .getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] })
-      .get("session.interrupt")
-    return formatKeyBindings(bindings, this.options.tuiConfig) || "esc"
-  }
-
   private clearExitTimer(): void {
     if (!this.exitTimeout) {
       return
@@ -926,12 +987,12 @@ export class RunFooter implements FooterApi {
 
     if (next < 2) {
       this.armInterruptTimer()
-      this.patch({ status: `${this.interruptHint()} again to interrupt` })
       return true
     }
 
     this.clearInterruptTimer()
-    this.patch({ interrupt: 0, status: "interrupting" })
+    this.patch({ interrupt: 0 })
+    this.setNotice("interrupting")
     this.options.onInterrupt?.()
     return true
   }
@@ -947,7 +1008,6 @@ export class RunFooter implements FooterApi {
 
     if (next < 2) {
       this.armExitTimer()
-      this.patch({ status: "Press Ctrl-c again to exit" })
       return true
     }
 
@@ -1044,6 +1104,7 @@ export class RunFooter implements FooterApi {
     this.notifyClose()
     this.clearInterruptTimer()
     this.clearExitTimer()
+    this.clearNoticeTimer()
     this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
     this.renderer.off(CliRenderEvents.PALETTE, this.handlePalette)
     this.renderer.off(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)

Разлика између датотеке није приказан због своје велике величине
+ 470 - 424
packages/opencode/src/cli/cmd/run/footer.view.tsx


+ 27 - 0
packages/opencode/src/cli/cmd/run/footer.width.ts

@@ -0,0 +1,27 @@
+// Shared responsive width policy
+
+const FOOTER_WIDTH_BREAKPOINTS = {
+  compact: 80,
+  commandHint: 66,
+  model: 120,
+  spacious: 150,
+} as const
+
+export function footerWidthPolicy(width: number) {
+  const compact = width >= FOOTER_WIDTH_BREAKPOINTS.compact
+  const model = width >= FOOTER_WIDTH_BREAKPOINTS.model
+  const spacious = width >= FOOTER_WIDTH_BREAKPOINTS.spacious
+
+  return {
+    dialog: {
+      narrow: !compact,
+    },
+    statusline: {
+      showActivityMeta: compact,
+      showCommandHint: width >= FOOTER_WIDTH_BREAKPOINTS.commandHint,
+      showContextHints: compact,
+      contextHintLimit: !compact ? 0 : spacious ? undefined : model ? 2 : 1,
+      showModel: model,
+    },
+  }
+}

+ 162 - 0
packages/opencode/src/cli/cmd/run/prompt.editor.ts

@@ -0,0 +1,162 @@
+import type { RunPromptPart } from "./types"
+
+type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
+
+export function resolveEditorSlashValue(text: string) {
+  const head = slashHead(text)
+  if (!head || head.name.toLowerCase() !== "editor") {
+    return text
+  }
+
+  return head.arguments
+}
+
+export function realignEditorPromptParts(content: string, parts: RunPromptPart[]): RunPromptPart[] {
+  const matches = new Map<number, Mention | undefined>()
+  const used: Array<{ start: number; end: number }> = []
+
+  for (const [index, part] of parts.entries()) {
+    if (part.type !== "file" && part.type !== "agent") {
+      continue
+    }
+
+    const text = promptPartText(part)
+    if (!text) {
+      continue
+    }
+
+    const start = findPromptPartIndex(content, text, used, promptPartStart(part))
+    if (start === -1) {
+      matches.set(index, undefined)
+      continue
+    }
+
+    const end = start + text.length
+    used.push({ start, end })
+    matches.set(index, updatePromptPart(part, start, end, text))
+  }
+
+  const next: RunPromptPart[] = []
+  for (const [index, part] of parts.entries()) {
+    if (part.type !== "file" && part.type !== "agent") {
+      next.push(part)
+      continue
+    }
+
+    if (!promptPartText(part)) {
+      next.push(part)
+      continue
+    }
+
+    const match = matches.get(index)
+    if (match) {
+      next.push(match)
+    }
+  }
+
+  return next
+}
+
+function slashHead(text: string) {
+  if (!text.startsWith("/")) {
+    return
+  }
+
+  for (let i = 1; i < text.length; i++) {
+    switch (text[i]) {
+      case " ":
+      case "\t":
+      case "\n":
+        return {
+          name: text.slice(1, i),
+          arguments: text.slice(i + 1),
+        }
+    }
+  }
+
+  return {
+    name: text.slice(1),
+    arguments: "",
+  }
+}
+
+function promptPartText(part: Mention) {
+  if (part.type === "agent") {
+    return part.source?.value
+  }
+
+  return part.source?.text.value
+}
+
+function promptPartStart(part: Mention) {
+  if (part.type === "agent") {
+    return part.source?.start ?? Number.POSITIVE_INFINITY
+  }
+
+  return part.source?.text.start ?? Number.POSITIVE_INFINITY
+}
+
+function findPromptPartIndex(
+  content: string,
+  text: string,
+  used: Array<{ start: number; end: number }>,
+  hint: number,
+) {
+  let searchFrom = 0
+  let best = -1
+  let distance = Number.POSITIVE_INFINITY
+  const hinted = Number.isFinite(hint)
+
+  while (true) {
+    const start = content.indexOf(text, searchFrom)
+    if (start === -1) {
+      return best
+    }
+
+    const end = start + text.length
+    searchFrom = start + 1
+    if (used.some((range) => start < range.end && end > range.start)) {
+      continue
+    }
+
+    if (!hinted) {
+      return start
+    }
+
+    const nextDistance = Math.abs(start - hint)
+    if (nextDistance < distance) {
+      best = start
+      distance = nextDistance
+    }
+  }
+}
+
+function updatePromptPart(part: Mention, start: number, end: number, text: string): Mention {
+  if (part.type === "agent") {
+    return {
+      ...part,
+      source: {
+        start,
+        end,
+        value: text,
+      },
+    }
+  }
+
+  if (!part.source?.text) {
+    return part
+  }
+
+  return {
+    ...part,
+    source: {
+      ...part.source,
+      text: {
+        ...part.source.text,
+        start,
+        end,
+        value: text,
+      },
+    },
+  }
+}

+ 7 - 1
packages/opencode/src/cli/cmd/run/prompt.shared.ts

@@ -30,11 +30,17 @@ export function promptCopy(prompt: RunPrompt): RunPrompt {
     text: prompt.text,
     parts: structuredClone(prompt.parts),
     ...(prompt.mode ? { mode: prompt.mode } : {}),
+    ...(prompt.command ? { command: prompt.command } : {}),
   }
 }
 
 export function promptSame(a: RunPrompt, b: RunPrompt): boolean {
-  return a.mode === b.mode && a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts)
+  return (
+    a.mode === b.mode &&
+    a.text === b.text &&
+    JSON.stringify(a.parts) === JSON.stringify(b.parts) &&
+    JSON.stringify(a.command) === JSON.stringify(b.command)
+  )
 }
 
 export function isExitCommand(input: string): boolean {

+ 72 - 11
packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts

@@ -8,10 +8,13 @@
 //
 // Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
 // back to the usual two-press exit sequence through RunFooter.requestExit().
+import path from "path"
 import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
 import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
-import { Session as SessionApi } from "@/session/session"
+import { Global } from "@opencode-ai/core/global"
+import { openEditor } from "@opencode-ai/tui/editor"
 import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
+import { Session as SessionApi } from "@/session/session"
 import * as Locale from "@/util/locale"
 import { withRunSpan } from "./otel"
 import { resolveInteractiveStdin } from "./runtime.stdin"
@@ -30,7 +33,7 @@ import type {
 } from "./types"
 import { formatModelLabel } from "./variant.shared"
 
-const FOOTER_HEIGHT = 7
+const FOOTER_HEIGHT = 4
 
 type SplashState = {
   entry: boolean
@@ -135,6 +138,17 @@ function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): Foo
   }
 }
 
+function directoryLabel(directory: string) {
+  const resolved = path.resolve(directory)
+  const display =
+    resolved === Global.Path.home
+      ? "~"
+      : resolved.startsWith(`${Global.Path.home}${path.sep}`)
+        ? resolved.replace(Global.Path.home, "~")
+        : resolved
+  return display.replaceAll("\\", "/")
+}
+
 function queueSplash(
   renderer: Pick<CliRenderer, "writeToScrollback" | "requestRender">,
   state: SplashState,
@@ -205,6 +219,11 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
           title: splash.title,
           session_id: input.sessionID,
         })
+        const labels = footerLabels({
+          agent: input.agent,
+          model: input.model,
+          variant: input.variant,
+        })
         const footerTask = import("./footer")
         const wrote = queueSplash(
           renderer,
@@ -214,17 +233,15 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
             ...meta,
             theme: theme.splash,
             showSession: splash.showSession,
+            detail: directoryLabel(input.directory),
           }),
         )
         await renderer.idle().catch(() => {})
 
         const { RunFooter } = await footerTask
+        let closed = false
+        let sigintRegistered = false
 
-        const labels = footerLabels({
-          agent: input.agent,
-          model: input.model,
-          variant: input.variant,
-        })
         const footer = new RunFooter(renderer, {
           directory: input.directory,
           findFiles: input.findFiles,
@@ -250,15 +267,54 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
           onVariantSelect: input.onVariantSelect,
           onInterrupt: input.onInterrupt,
           onBackground: input.onBackground,
+          onEditorOpen: async ({ value }) => {
+            if (closed || renderer.isDestroyed) {
+              return
+            }
+
+            await renderer.idle().catch(() => {})
+            const ignore = () => {}
+            detachSigint()
+            process.on("SIGINT", ignore)
+            try {
+              return await openEditor({
+                value,
+                cwd: input.directory,
+                renderer,
+                stdin: source.stdin,
+              })
+            } finally {
+              process.off("SIGINT", ignore)
+              attachSigint()
+            }
+          },
           onSubagentSelect: input.onSubagentSelect,
         })
 
         const sigint = () => {
           footer.requestExit()
         }
-        process.on("SIGINT", sigint)
 
-        let closed = false
+        const attachSigint = () => {
+          if (closed || sigintRegistered) {
+            return
+          }
+
+          process.on("SIGINT", sigint)
+          sigintRegistered = true
+        }
+
+        const detachSigint = () => {
+          if (!sigintRegistered) {
+            return
+          }
+
+          process.off("SIGINT", sigint)
+          sigintRegistered = false
+        }
+
+        attachSigint()
+
         const close = async (next: {
           showExit: boolean
           sessionTitle?: string
@@ -277,7 +333,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
               "session.id": next.sessionID || input.getSessionID?.() || input.sessionID || undefined,
             },
             async () => {
-              process.off("SIGINT", sigint)
+              detachSigint()
+              let wroteExit = false
 
               try {
                 await footer.idle().catch(() => {})
@@ -286,7 +343,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
                 if (!renderer.isDestroyed && show) {
                   const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID
                   const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history)
-                  queueSplash(
+                  wroteExit = queueSplash(
                     renderer,
                     state,
                     "exit",
@@ -306,6 +363,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
                 footer.destroy()
                 unregisterKeymap?.()
                 shutdown(renderer)
+                if (!wroteExit) {
+                  process.stdout.write("\n")
+                }
                 source.cleanup?.()
               }
             },
@@ -353,6 +413,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
                 }),
                 theme: footer.currentTheme().splash,
                 showSession: splash.showSession,
+                detail: directoryLabel(input.directory),
               }),
             )
             renderer.requestRender()

+ 12 - 10
packages/opencode/src/cli/cmd/run/runtime.queue.ts

@@ -228,16 +228,18 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
               state.ctrl = undefined
             }
 
-            const duration = Locale.duration(Math.max(0, Date.now() - start))
-            emit(
-              {
-                type: "turn.duration",
-                duration,
-              },
-              {
-                duration,
-              },
-            )
+            if (sent.mode !== "shell") {
+              const duration = Locale.duration(Math.max(0, Date.now() - start))
+              emit(
+                {
+                  type: "turn.duration",
+                  duration,
+                },
+                {
+                  duration,
+                },
+              )
+            }
             state.active = undefined
           }
         }

+ 49 - 26
packages/opencode/src/cli/cmd/run/runtime.ts

@@ -77,9 +77,19 @@ type RunLocalInput = {
   demo?: RunInput["demo"]
 }
 
+type StreamTransportModule = Pick<
+  Awaited<typeof import("./stream.transport")>,
+  "createSessionTransport" | "formatUnknownError"
+>
+
+export type RunRuntimeDeps = {
+  createRuntimeLifecycle?: typeof createRuntimeLifecycle
+  streamTransport?: Promise<StreamTransportModule>
+}
+
 type StreamState = {
-  mod: Awaited<typeof import("./stream.transport")>
-  handle: Awaited<ReturnType<Awaited<typeof import("./stream.transport")>["createSessionTransport"]>>
+  mod: StreamTransportModule
+  handle: Awaited<ReturnType<StreamTransportModule["createSessionTransport"]>>
 }
 
 type ResolvedSession = {
@@ -169,7 +179,7 @@ async function resolveExitTitle(
 //
 // Files only attach on the first prompt turn -- after that, includeFiles
 // flips to false so subsequent turns don't re-send attachments.
-async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
+async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise<void> {
   return withRunSpan(
     "RunInteractive.session",
     {
@@ -237,7 +247,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
         return state.session
       }
 
-      const shell = await createRuntimeLifecycle({
+      const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({
         directory: ctx.directory,
         findFiles: (query) =>
           ctx.sdk.find
@@ -479,7 +489,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
         })
       })
 
-      const streamTask = import("./stream.transport")
+      const streamTask = deps.streamTransport ?? import("./stream.transport")
       const ensureStream = () => {
         if (state.stream) {
           return state.stream
@@ -506,6 +516,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
             replay: input.replay,
             replayLimit: input.replayLimit,
             limits: () => state.limits,
+            providers: () => state.providers,
             footer,
             trace: log,
           })
@@ -746,6 +757,12 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
       try {
         const eager = eagerStream(input, ctx)
         if (eager) {
+          if (input.replay && state.shown) {
+            // Replay commits immutable scrollback rows, so wait for provider names
+            // before bootstrapping existing session history.
+            await modelTask
+          }
+
           await ensureStream()
         }
 
@@ -846,7 +863,10 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
 }
 
 // Attach mode. Uses the caller-provided SDK client directly.
-export async function runInteractiveMode(input: RunInput & { createSession?: CreateSession }): Promise<void> {
+export async function runInteractiveMode(
+  input: RunInput & { createSession?: CreateSession },
+  deps?: RunRuntimeDeps,
+): Promise<void> {
   return withRunSpan(
     "RunInteractive.attachMode",
     {
@@ -855,25 +875,28 @@ export async function runInteractiveMode(input: RunInput & { createSession?: Cre
       "session.id": input.sessionID,
     },
     async () =>
-      runInteractiveRuntime({
-        files: input.files,
-        initialInput: input.initialInput,
-        thinking: input.thinking,
-        backgroundSubagents: input.backgroundSubagents,
-        replay: input.replay,
-        replayLimit: input.replayLimit,
-        demo: input.demo,
-        boot: async () => ({
-          sdk: input.sdk,
-          directory: input.directory,
-          sessionID: input.sessionID,
-          sessionTitle: input.sessionTitle,
-          resume: input.resume,
-          agent: input.agent,
-          model: input.model,
-          variant: input.variant,
-        }),
-        createSession: createSessionResolver(input.createSession),
-      }),
+      runInteractiveRuntime(
+        {
+          files: input.files,
+          initialInput: input.initialInput,
+          thinking: input.thinking,
+          backgroundSubagents: input.backgroundSubagents,
+          replay: input.replay,
+          replayLimit: input.replayLimit,
+          demo: input.demo,
+          boot: async () => ({
+            sdk: input.sdk,
+            directory: input.directory,
+            sessionID: input.sessionID,
+            sessionTitle: input.sessionTitle,
+            resume: input.resume,
+            agent: input.agent,
+            model: input.model,
+            variant: input.variant,
+          }),
+          createSession: createSessionResolver(input.createSession),
+        },
+        deps,
+      ),
   )
 }

+ 14 - 1
packages/opencode/src/cli/cmd/run/scrollback.surface.ts

@@ -16,7 +16,8 @@ import {
 import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
 import { withRunSpan } from "./otel"
 import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
-import { entryWriter, sameEntryGroup, separatorRows, spacerWriter } from "./scrollback.writer"
+import { turnSummaryCommit } from "./turn-summary"
+import { entryWriter, sameEntryGroup, separatorRows, spacerWriter, turnSummaryWriter } from "./scrollback.writer"
 import { type RunTheme } from "./theme"
 import type { RunDiffStyle, RunEntryBody, StreamCommit } from "./types"
 
@@ -357,6 +358,14 @@ export class RunScrollbackStream {
       this.markRendered(await this.finishActive(false))
     }
 
+    if (commit.summary) {
+      this.writeSpacer(1)
+      this.renderer.writeToScrollback(turnSummaryWriter({ ...commit.summary, theme: this.theme }))
+      this.markRendered(commit)
+      this.tail = commit
+      return
+    }
+
     const body = entryBody(commit)
     if (body.type === "none") {
       if (entryDone(commit)) {
@@ -428,6 +437,10 @@ export class RunScrollbackStream {
     )
   }
 
+  public async writeTurnSummary(input: { agent: string; model: string; duration: string }): Promise<void> {
+    await this.append(turnSummaryCommit(input))
+  }
+
   public destroy(): void {
     this.resetActive()
     this.releasePendingThemes()

+ 16 - 1
packages/opencode/src/cli/cmd/run/scrollback.writer.tsx

@@ -24,7 +24,7 @@ function todoText(item: { status: string; content: string }): string {
 }
 
 function todoColor(theme: RunTheme, status: string) {
-  return status === "in_progress" ? theme.footer.warning : theme.block.muted
+  return status === "in_progress" ? theme.block.warning : theme.block.muted
 }
 
 export function entryGroupKey(commit: StreamCommit): string | undefined {
@@ -333,3 +333,18 @@ export function spacerWriter(): ScrollbackWriter {
     trailingNewline: true,
   })
 }
+
+export function turnSummaryWriter(input: { agent: string; model: string; duration: string; theme: RunTheme }) {
+  return createScrollbackWriter(
+    () => (
+      <box width="100%" height={1}>
+        <text wrapMode="none" truncate>
+          <span style={{ fg: input.theme.block.highlight }}>▣ </span>
+          <span style={{ fg: input.theme.block.text }}>{input.agent}</span>
+          <span style={{ fg: input.theme.block.muted }}> · {input.model} · {input.duration}</span>
+        </text>
+      </box>
+    ),
+    { startOnNewLine: true, trailingNewline: false },
+  )
+}

+ 76 - 5
packages/opencode/src/cli/cmd/run/session-replay.ts

@@ -1,7 +1,8 @@
 import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
 import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data"
 import { messagePrompt, type SessionMessages } from "./session.shared"
-import type { FooterPatch, LocalReplayRow, StreamCommit } from "./types"
+import { messageTurnSummaryCommit } from "./turn-summary"
+import type { FooterPatch, LocalReplayRow, RunProvider, StreamCommit } from "./types"
 
 type ReplayInput = {
   messages: SessionMessages
@@ -9,6 +10,13 @@ type ReplayInput = {
   questions: QuestionRequest[]
   thinking: boolean
   limits: Record<string, number>
+  providers?: RunProvider[]
+}
+
+type ReplayConfig = {
+  limits: Record<string, number>
+  providers?: RunProvider[]
+  summaries: ReadonlySet<string>
 }
 
 export type SessionReplay = {
@@ -22,6 +30,8 @@ type ReplayMessage = {
   patch?: FooterPatch
 }
 
+const SHELL_SYNTHETIC_USER_TEXT = "The following tool was executed by the user"
+
 function apply(data: SessionData, event: Event, sessionID: string, thinking: boolean, limits: Record<string, number>) {
   return reduceSessionData({
     data,
@@ -89,11 +99,62 @@ function replayPatch(data: SessionData, patch: FooterPatch | undefined) {
   } satisfies FooterPatch
 }
 
+function isShellSyntheticUser(message: SessionMessages[number]) {
+  if (message.info.role !== "user") {
+    return false
+  }
+
+  const prompt = messagePrompt(message)
+  return (
+    !prompt.text.trim() &&
+    prompt.parts.length === 0 &&
+    message.parts.some((part) => part.type === "text" && part.synthetic && part.text === SHELL_SYNTHETIC_USER_TEXT)
+  )
+}
+
+function isShellSyntheticAssistant(message: SessionMessages[number], shellParents: ReadonlySet<string>) {
+  return (
+    message.info.role === "assistant" &&
+    shellParents.has(message.info.parentID) &&
+    message.parts.some((part) => part.type === "tool" && part.tool === "bash")
+  )
+}
+
+function summaryMessageIDs(messages: SessionMessages): ReadonlySet<string> {
+  const shellParents = new Set(messages.filter(isShellSyntheticUser).map((message) => message.info.id))
+  const parents = new Set<string>()
+  const summaries = new Set<string>()
+
+  for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
+    const message = messages[idx]
+    if (!message || message.info.role !== "assistant") {
+      continue
+    }
+
+    if (isShellSyntheticAssistant(message, shellParents)) {
+      continue
+    }
+
+    if (parents.has(message.info.parentID)) {
+      continue
+    }
+
+    parents.add(message.info.parentID)
+
+    const completed = message.info.time.completed
+    if (typeof completed === "number" && completed > message.info.time.created) {
+      summaries.add(message.info.id)
+    }
+  }
+
+  return summaries
+}
+
 function replayMessage(
   data: SessionData,
   message: SessionMessages[number],
   thinking: boolean,
-  limits: Record<string, number>,
+  config: ReplayConfig,
 ): ReplayMessage {
   if (message.info.role === "user") {
     const prompt = messagePrompt(message)
@@ -131,7 +192,7 @@ function replayMessage(
     },
     message.info.sessionID,
     thinking,
-    limits,
+    config.limits,
   )
   commits.push(...info.commits)
   patch = mergePatch(patch, info.footer?.patch)
@@ -150,12 +211,17 @@ function replayMessage(
       },
       message.info.sessionID,
       thinking,
-      limits,
+      config.limits,
     )
     patch = mergePatch(patch, next.footer?.patch)
     commits.push(...next.commits)
   }
 
+  const summary = config.summaries.has(message.info.id) ? messageTurnSummaryCommit(message, config.providers) : undefined
+  if (summary) {
+    commits.push(summary)
+  }
+
   return {
     commits,
     patch,
@@ -166,6 +232,7 @@ export function replaySession(input: ReplayInput): SessionReplay {
   const data = createSessionData()
   const commits: StreamCommit[] = []
   let patch: FooterPatch | undefined
+  const summaries = summaryMessageIDs(input.messages)
 
   bootstrapSessionData({
     data,
@@ -175,7 +242,11 @@ export function replaySession(input: ReplayInput): SessionReplay {
   })
 
   for (const message of input.messages) {
-    const next = replayMessage(data, message, input.thinking, input.limits)
+    const next = replayMessage(data, message, input.thinking, {
+      limits: input.limits,
+      providers: input.providers,
+      summaries,
+    })
     commits.push(...next.commits)
     patch = mergePatch(patch, next.patch)
   }

+ 22 - 48
packages/opencode/src/cli/cmd/run/splash.ts

@@ -11,7 +11,6 @@
 import {
   BoxRenderable,
   type ColorInput,
-  RGBA,
   TextAttributes,
   TextRenderable,
   type ScrollbackRenderContext,
@@ -19,7 +18,7 @@ import {
   type ScrollbackWriter,
 } from "@opentui/core"
 import * as Locale from "@/util/locale"
-import { go, logo } from "@/cli/logo"
+import { go } from "@/cli/logo"
 import type { RunSplashTheme } from "./theme"
 
 export const SPLASH_TITLE_LIMIT = 50
@@ -33,6 +32,7 @@ type SplashInput = {
 type SplashWriterInput = SplashInput & {
   theme: RunSplashTheme
   showSession?: boolean
+  detail?: string
 }
 
 export type SplashMeta = {
@@ -144,28 +144,6 @@ function push(
   lines.push({ left, top, text, fg, bg, attrs })
 }
 
-function color(input: ColorInput, fallback: RGBA): RGBA {
-  if (input instanceof RGBA) {
-    return input
-  }
-
-  if (typeof input === "string") {
-    if (input === "transparent" || input === "none") {
-      return RGBA.fromValues(0, 0, 0, 0)
-    }
-
-    if (input.startsWith("#")) {
-      return RGBA.fromHex(input)
-    }
-  }
-
-  return fallback
-}
-
-function fallback(index: number, hex: string): RGBA {
-  return RGBA.fromIndex(index, RGBA.fromHex(hex))
-}
-
 function draw(
   lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }>,
   row: string,
@@ -200,41 +178,37 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
   const width = Math.max(1, ctx.width)
   const meta = splashMeta(input)
   const lines: Array<{ left: number; top: number; text: string; fg: ColorInput; bg?: ColorInput; attrs?: number }> = []
-  const left = color(input.theme.left, fallback(81, "#38bdf8"))
-  const right = color(input.theme.right, RGBA.defaultForeground(RGBA.fromHex("#f8fafc")))
-  const leftShadow = color(input.theme.leftShadow, fallback(238, "#334155"))
+  const left = input.theme.left
+  const right = input.theme.right
+  const leftShadow = input.theme.leftShadow
   let height = 1
 
   if (kind === "entry") {
-    const rightShadow = color(input.theme.rightShadow, fallback(240, "#475569"))
-
-    for (let i = 0; i < logo.left.length; i += 1) {
-      const leftText = logo.left[i] ?? ""
-      const rightText = logo.right[i] ?? ""
+    const mark = go.right.slice(1)
+    const top = 1
+    const body_left = (mark[0]?.length ?? 0) + 2
 
-      draw(lines, leftText, {
+    for (let i = 0; i < mark.length; i += 1) {
+      draw(lines, mark[i] ?? "", {
         left: 0,
-        top: i,
+        top: top + i,
         fg: left,
         shadow: leftShadow,
       })
-      draw(lines, rightText, {
-        left: leftText.length + 1,
-        top: i,
-        fg: right,
-        shadow: rightShadow,
-      })
     }
 
-    height = logo.left.length
-
-    if (input.showSession !== false) {
-      const top = logo.left.length + 1
-      const label = "Session".padEnd(10, " ")
-      push(lines, 0, top, label, left, undefined, TextAttributes.DIM)
-      push(lines, label.length, top, meta.title, right, undefined, TextAttributes.BOLD)
-      height = top + 1
+    push(lines, body_left, top, "OpenCode", right, undefined, TextAttributes.BOLD)
+    if (input.detail) {
+      push(
+        lines,
+        body_left,
+        top + 1,
+        Locale.truncateMiddle(input.detail, Math.max(1, width - body_left)),
+        left,
+        undefined,
+      )
     }
+    height = top + mark.length
   }
 
   if (kind === "exit") {

+ 9 - 12
packages/opencode/src/cli/cmd/run/stream.transport.ts

@@ -31,7 +31,6 @@ import { replayActiveText, replayLocalRows, replaySession } from "./session-repl
 import {
   bootstrapSubagentCalls,
   bootstrapSubagentData,
-  clearFinishedSubagents,
   createSubagentData,
   listSubagentPermissions,
   listSubagentQuestions,
@@ -57,6 +56,7 @@ import type {
   RunInput,
   RunPrompt,
   RunPromptPart,
+  RunProvider,
   StreamCommit,
 } from "./types"
 
@@ -74,6 +74,7 @@ type StreamInput = {
   replay?: boolean
   replayLimit?: number
   limits: () => Record<string, number>
+  providers?: () => RunProvider[]
   footer: FooterApi
   trace?: Trace
   signal?: AbortSignal
@@ -712,9 +713,10 @@ function createLayer(input: StreamInput) {
                 messages: messagesList,
                 permissions: sessionPermissions,
                 questions: sessionQuestions,
-                thinking: input.thinking,
-                limits: input.limits(),
-              })
+              thinking: input.thinking,
+              limits: input.limits(),
+              providers: input.providers?.(),
+            })
             : undefined
           const replay =
             history && input.replayLimit !== undefined && messagesList.length > input.replayLimit
@@ -724,6 +726,7 @@ function createLayer(input: StreamInput) {
                   questions: sessionQuestions,
                   thinking: input.thinking,
                   limits: input.limits(),
+                  providers: input.providers?.(),
                 })
               : history
 
@@ -751,7 +754,6 @@ function createLayer(input: StreamInput) {
             permissions,
             questions,
           })
-          clearFinishedSubagents(state.subagent)
 
           for (const request of [
             ...state.data.permissions,
@@ -1026,6 +1028,7 @@ function createLayer(input: StreamInput) {
                 questions: sessionQuestions,
                 thinking: input.thinking,
                 limits: input.limits(),
+                providers: input.providers?.(),
               })
               const activeCommits = replayActiveText(history.data, state.data)
               return {
@@ -1043,6 +1046,7 @@ function createLayer(input: StreamInput) {
                         questions: sessionQuestions,
                         thinking: input.thinking,
                         limits: input.limits(),
+                        providers: input.providers?.(),
                       })
                     : history,
               }
@@ -1195,13 +1199,6 @@ function createLayer(input: StreamInput) {
             return
           }
 
-          const prev = listSubagentTabs(state.subagent)
-          if (clearFinishedSubagents(state.subagent)) {
-            const snapshot = currentSubagentState()
-            traceTabs(input.trace, prev, snapshot.tabs)
-            syncFooter([], undefined, snapshot)
-          }
-
           const item: Wait = {
             tick: state.tick,
             armed: false,

+ 60 - 29
packages/opencode/src/cli/cmd/run/subagent-data.ts

@@ -292,10 +292,25 @@ function metadata(part: ToolPart, key: string) {
   return ("metadata" in part.state ? part.state.metadata?.[key] : undefined) ?? part.metadata?.[key]
 }
 
+function taskStatus(part: ToolPart): FooterSubagentTab["status"] {
+  if (part.state.status === "completed") {
+    return "completed"
+  }
+
+  if (part.state.status === "error") {
+    if (metadata(part, "interrupted") === true || text(part.state.error) === "Tool execution aborted") {
+      return "cancelled"
+    }
+
+    return "error"
+  }
+
+  return "running"
+}
+
 function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab {
   const label = Locale.titlecase(text(part.state.input.subagent_type) ?? "general")
   const description = text(part.state.input.description) ?? stateTitle(part) ?? inputLabel(part.state.input) ?? ""
-  const status = part.state.status === "error" ? "error" : part.state.status === "completed" ? "completed" : "running"
 
   return {
     sessionID,
@@ -303,7 +318,7 @@ function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab {
     callID: part.callID,
     label,
     description,
-    status,
+    status: taskStatus(part),
     background: metadata(part, "background") === true,
     title: stateTitle(part),
     toolCalls: num(metadata(part, "toolcalls")) ?? num(metadata(part, "toolCalls")) ?? num(metadata(part, "calls")),
@@ -457,6 +472,29 @@ function ensureBlockerTab(
   return true
 }
 
+function isAbortedAssistantMessage(info: Message) {
+  return info.role === "assistant" && info.error?.name === "MessageAbortedError"
+}
+
+function cancelSubagentTab(data: SubagentData, sessionID: string) {
+  const current = data.tabs.get(sessionID)
+  if (!current || current.status !== "running") {
+    return false
+  }
+
+  const next = {
+    ...current,
+    status: "cancelled" as const,
+    lastUpdatedAt: Date.now(),
+  }
+  if (sameSubagentTab(current, next)) {
+    return false
+  }
+
+  data.tabs.set(sessionID, next)
+  return true
+}
+
 function compactCallMap(detail: DetailState) {
   const keep = new Set(recent(detail.data.call.keys(), SUBAGENT_CALL_LIMIT))
 
@@ -751,22 +789,6 @@ export function bootstrapSubagentCalls(input: {
   return changed || beforeCallCount !== detail.data.call.size || queueChanged(detail.data, before)
 }
 
-export function clearFinishedSubagents(data: SubagentData) {
-  let changed = false
-
-  for (const [sessionID, tab] of data.tabs.entries()) {
-    if (tab.status === "running") {
-      continue
-    }
-
-    data.tabs.delete(sessionID)
-    data.details.delete(sessionID)
-    changed = true
-  }
-
-  return changed
-}
-
 export function reduceSubagentData(input: {
   data: SubagentData
   event: Event
@@ -807,12 +829,16 @@ export function reduceSubagentData(input: {
   }
 
   const detail = ensureDetail(input.data, sessionID)
+  const cancelled = event.type === "message.updated" && isAbortedAssistantMessage(event.properties.info)
+    ? cancelSubagentTab(input.data, sessionID)
+    : false
   if (event.type === "session.status") {
     if (event.properties.status.type !== "retry") {
-      return false
+      return cancelled
     }
 
-    return appendCommits(detail, [
+    return (
+      appendCommits(detail, [
       {
         kind: "error",
         text: event.properties.status.message,
@@ -820,11 +846,13 @@ export function reduceSubagentData(input: {
         source: "system",
         messageID: `retry:${event.properties.status.attempt}`,
       },
-    ])
+      ]) || cancelled
+    )
   }
 
   if (event.type === "session.error" && event.properties.error) {
-    return appendCommits(detail, [
+    return (
+      appendCommits(detail, [
       {
         kind: "error",
         text: formatError(event.properties.error),
@@ -832,13 +860,16 @@ export function reduceSubagentData(input: {
         source: "system",
         messageID: `session.error:${event.properties.sessionID}:${formatError(event.properties.error)}`,
       },
-    ])
+      ]) || cancelled
+    )
   }
 
-  return applyChildEvent({
-    detail,
-    event,
-    thinking: input.thinking,
-    limits: input.limits,
-  })
+  return (
+    applyChildEvent({
+      detail,
+      event,
+      thinking: input.thinking,
+      limits: input.limits,
+    }) || cancelled
+  )
 }

+ 137 - 50
packages/opencode/src/cli/cmd/run/theme.ts

@@ -25,11 +25,15 @@ export type RunSplashTheme = {
 
 export type RunFooterTheme = {
   highlight: ColorInput
+  selected: ColorInput
+  selectedText: ColorInput
   warning: ColorInput
   success: ColorInput
   error: ColorInput
   muted: ColorInput
   text: ColorInput
+  status: ColorInput
+  statusAccent: ColorInput
   shade: ColorInput
   surface: ColorInput
   pane: ColorInput
@@ -38,6 +42,8 @@ export type RunFooterTheme = {
 }
 
 export type RunBlockTheme = {
+  highlight: ColorInput
+  warning: ColorInput
   text: ColorInput
   muted: ColorInput
   syntax?: SyntaxStyle
@@ -95,8 +101,11 @@ function rgba(hex: string, value?: number): RGBA {
 }
 
 function mode(bg: RGBA): "dark" | "light" {
-  const lum = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b
-  return lum > 0.5 ? "light" : "dark"
+  return luminance(bg) > 0.5 ? "light" : "dark"
+}
+
+function luminance(color: RGBA): number {
+  return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
 }
 
 function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: number): RGBA {
@@ -206,13 +215,39 @@ function indexedPalette(colors: TerminalColors, size: number = Math.max(colors.p
   })
 }
 
+function srgbToLinear(value: number): number {
+  if (value <= 0.04045) {
+    return value / 12.92
+  }
+
+  return ((value + 0.055) / 1.055) ** 2.4
+}
+
+function oklab(color: RGBA) {
+  const r = srgbToLinear(color.r)
+  const g = srgbToLinear(color.g)
+  const b = srgbToLinear(color.b)
+
+  const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
+  const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
+  const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
+
+  return {
+    l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
+    a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
+    b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
+  }
+}
+
 function nearestIndexed(indexed: RGBA[], rgba: RGBA): RGBA {
+  const target = oklab(rgba)
   const hit = indexed.reduce(
     (best, item) => {
-      const dr = item.r - rgba.r
-      const dg = item.g - rgba.g
-      const db = item.b - rgba.b
-      const dist = dr * dr + dg * dg + db * db
+      const sample = oklab(item)
+      const dl = sample.l - target.l
+      const da = sample.a - target.a
+      const db = sample.b - target.b
+      const dist = dl * dl * 2 + da * da + db * db
       if (dist >= best.dist) return best
       return {
         dist,
@@ -228,6 +263,11 @@ function nearestIndexed(indexed: RGBA[], rgba: RGBA): RGBA {
   return RGBA.clone(hit.item)
 }
 
+function paletteColor(colors: TerminalColors, index: number): RGBA {
+  const value = colors.palette[index]
+  return value ? RGBA.fromHex(value) : ansiToRgba(index)
+}
+
 function splashShadow(indexed: RGBA[], base: RGBA, overlay: RGBA, value: number): RGBA {
   const mixed = tint(base, overlay, value)
   return nearestIndexed(indexed, mixed)
@@ -346,13 +386,10 @@ export function generateSystem(colors: TerminalColors, pick: "dark" | "light"):
   const fg = RGBA.defaultForeground(fg_snapshot)
   const isDark = pick === "dark"
 
-  const indexed = indexedPalette(colors)
-  const color = (index: number) => RGBA.clone(indexed[index]!)
-  const nearest = (rgba: RGBA) => nearestIndexed(indexed, rgba)
+  const color = (index: number) => paletteColor(colors, index)
 
-  const grays = generateGrayScale(bg_snapshot, isDark, nearest)
-  const menu_grays = generateGrayScale(bg_snapshot, isDark, (rgba) => rgba)
-  const textMuted = generateMutedTextColor(bg_snapshot, isDark, nearest)
+  const grays = generateGrayScale(bg_snapshot, isDark, (rgba) => rgba)
+  const textMuted = generateMutedTextColor(bg_snapshot, isDark, (rgba) => rgba)
 
   const ansi = {
     red: color(1),
@@ -385,7 +422,7 @@ export function generateSystem(colors: TerminalColors, pick: "dark" | "light"):
       background: alpha(bg, 0),
       backgroundPanel: grays[2],
       backgroundElement: grays[3],
-      backgroundMenu: menu_grays[3],
+      backgroundMenu: grays[3],
       borderSubtle: grays[6],
       border: grays[7],
       borderActive: grays[8],
@@ -395,12 +432,12 @@ export function generateSystem(colors: TerminalColors, pick: "dark" | "light"):
       diffHunkHeader: grays[7],
       diffHighlightAdded: ansi.green_bright,
       diffHighlightRemoved: ansi.red_bright,
-      diffAddedBg: nearest(tint(bg_snapshot, ansi.green, diff_alpha)),
-      diffRemovedBg: nearest(tint(bg_snapshot, ansi.red, diff_alpha)),
+      diffAddedBg: tint(bg_snapshot, ansi.green, diff_alpha),
+      diffRemovedBg: tint(bg_snapshot, ansi.red, diff_alpha),
       diffContextBg: diff_context_bg,
       diffLineNumber: textMuted,
-      diffAddedLineNumberBg: nearest(tint(diff_context_bg, ansi.green, diff_alpha)),
-      diffRemovedLineNumberBg: nearest(tint(diff_context_bg, ansi.red, diff_alpha)),
+      diffAddedLineNumberBg: tint(diff_context_bg, ansi.green, diff_alpha),
+      diffRemovedLineNumberBg: tint(diff_context_bg, ansi.red, diff_alpha),
       markdownText: fg,
       markdownHeading: fg,
       markdownLink: ansi.blue,
@@ -428,6 +465,27 @@ export function generateSystem(colors: TerminalColors, pick: "dark" | "light"):
   }
 }
 
+function quantizeColor(indexed: RGBA[], rgba: RGBA): RGBA {
+  if (rgba.a === 0 || rgba.intent === "default" || rgba.intent === "indexed") {
+    return RGBA.clone(rgba)
+  }
+
+  return nearestIndexed(indexed, rgba)
+}
+
+function quantizeTheme(theme: TuiThemeCurrent, indexed: RGBA[]): TuiThemeCurrent {
+  const resolved = Object.fromEntries(
+    Object.entries(theme)
+      .filter(([key]) => key !== "thinkingOpacity")
+      .map(([key, value]) => [key, quantizeColor(indexed, value as RGBA)]),
+  ) as Partial<Record<ThemeColor, RGBA>>
+
+  return {
+    ...(resolved as Record<ThemeColor, RGBA>),
+    thinkingOpacity: theme.thinkingOpacity,
+  }
+}
+
 function splashTheme(theme: TuiThemeCurrent, indexed: RGBA[]): RunSplashTheme {
   const left = nearestIndexed(indexed, theme.textMuted)
   const right = nearestIndexed(indexed, theme.text)
@@ -440,69 +498,85 @@ function splashTheme(theme: TuiThemeCurrent, indexed: RGBA[]): RunSplashTheme {
 }
 
 function map(
-  theme: TuiThemeCurrent,
+  footerTheme: TuiThemeCurrent,
+  scrollbackTheme: TuiThemeCurrent,
   splash: RunSplashTheme,
   syntax?: SyntaxStyle,
   subtleSyntax?: SyntaxStyle,
 ): RunTheme {
-  const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, theme.background)
+  const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, scrollbackTheme.background)
   subtleSyntax?.destroy()
-  const shade = fade(theme.backgroundMenu, theme.background, 0.12, 0.56, 0.72)
-  const surface = fade(theme.backgroundMenu, theme.background, 0.18, 0.76, 0.9)
-  const line = fade(theme.backgroundMenu, theme.background, 0.24, 0.9, 0.98)
+  const footerBackground = alpha(footerTheme.background, 1)
+  const footerMode = mode(footerBackground)
+  const shade = fade(footerTheme.backgroundMenu, footerTheme.background, 0.12, 0.56, 0.72)
+  const surface = fade(footerTheme.backgroundMenu, footerTheme.background, 0.18, 0.76, 0.9)
+  const line = fade(footerTheme.backgroundMenu, footerTheme.background, 0.24, 0.9, 0.98)
+  const statusBase = tint(footerBackground, rgba("#000000"), footerMode === "dark" ? 0.12 : 0.06)
+  const statusAccentBase =
+    footerMode === "dark" ? tint(footerBackground, rgba("#ffffff"), 0.06) : tint(statusBase, rgba("#000000"), 0.04)
+  const collapsedStatus = footerMode === "dark" && luminance(statusBase) <= 0.04
+  // Pure-black backgrounds need a slight lift or the row disappears into the terminal background.
+  const status = collapsedStatus ? tint(statusBase, statusAccentBase, 0.7) : statusBase
+  const statusAccent = collapsedStatus ? tint(status, rgba("#ffffff"), 0.06) : statusAccentBase
 
   return {
-    background: theme.background,
+    background: footerTheme.background,
     footer: {
-      highlight: theme.primary,
-      warning: theme.warning,
-      success: theme.success,
-      error: theme.error,
-      muted: theme.textMuted,
-      text: theme.text,
+      highlight: footerTheme.primary,
+      selected: footerTheme.backgroundElement,
+      selectedText: footerTheme.selectedListItemText,
+      warning: footerTheme.warning,
+      success: footerTheme.success,
+      error: footerTheme.error,
+      muted: footerTheme.textMuted,
+      text: footerTheme.text,
+      status,
+      statusAccent,
       shade,
       surface,
-      pane: theme.backgroundMenu,
-      border: theme.border,
+      pane: footerTheme.backgroundMenu,
+      border: footerTheme.border,
       line,
     },
     entry: {
       system: {
-        body: theme.textMuted,
+        body: scrollbackTheme.textMuted,
       },
       user: {
-        body: theme.primary,
+        body: scrollbackTheme.primary,
       },
       assistant: {
-        body: theme.text,
+        body: scrollbackTheme.text,
       },
       reasoning: {
-        body: theme.textMuted,
+        body: scrollbackTheme.textMuted,
       },
       tool: {
-        body: theme.text,
-        start: theme.textMuted,
+        body: scrollbackTheme.text,
+        start: scrollbackTheme.textMuted,
       },
       error: {
-        body: theme.error,
+        body: scrollbackTheme.error,
       },
     },
     splash,
     block: {
-      text: theme.text,
-      muted: theme.textMuted,
+      highlight: scrollbackTheme.primary,
+      warning: scrollbackTheme.warning,
+      text: scrollbackTheme.text,
+      muted: scrollbackTheme.textMuted,
       syntax,
       subtleSyntax: opaqueSubtleSyntax,
-      diffAdded: theme.diffAdded,
-      diffRemoved: theme.diffRemoved,
+      diffAdded: scrollbackTheme.diffAdded,
+      diffRemoved: scrollbackTheme.diffRemoved,
       diffAddedBg: transparent,
       diffRemovedBg: transparent,
       diffContextBg: transparent,
-      diffHighlightAdded: theme.diffHighlightAdded,
-      diffHighlightRemoved: theme.diffHighlightRemoved,
-      diffLineNumber: theme.diffLineNumber,
-      diffAddedLineNumberBg: theme.diffAddedLineNumberBg,
-      diffRemovedLineNumberBg: theme.diffRemovedLineNumberBg,
+      diffHighlightAdded: scrollbackTheme.diffHighlightAdded,
+      diffHighlightRemoved: scrollbackTheme.diffHighlightRemoved,
+      diffLineNumber: scrollbackTheme.diffLineNumber,
+      diffAddedLineNumberBg: scrollbackTheme.diffAddedLineNumberBg,
+      diffRemovedLineNumberBg: scrollbackTheme.diffRemovedLineNumberBg,
     },
   }
 }
@@ -532,11 +606,15 @@ export const RUN_THEME_FALLBACK: RunTheme = {
   background: RGBA.fromValues(0, 0, 0, 0),
   footer: {
     highlight: seed.highlight,
+    selected: seed.text,
+    selectedText: seed.panel,
     warning: seed.warning,
     success: seed.success,
     error: seed.error,
     muted: seed.muted,
     text: seed.text,
+    status: tint(seed.panel, rgba("#000000"), 0.12),
+    statusAccent: tint(seed.panel, rgba("#ffffff"), 0.06),
     shade: alpha(seed.panel, 0.68),
     surface: alpha(seed.panel, 0.86),
     pane: seed.panel,
@@ -558,6 +636,8 @@ export const RUN_THEME_FALLBACK: RunTheme = {
     rightShadow: splashShadow(fallbackSplashIndexed, RGBA.fromValues(0, 0, 0, 0), fallbackSplashRight, 0.14),
   },
   block: {
+    highlight: seed.highlight,
+    warning: seed.warning,
     text: seed.text,
     muted: seed.muted,
     diffAdded: seed.success,
@@ -588,15 +668,22 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
     const pick = colors.defaultBackground
       ? mode(RGBA.fromHex(colors.defaultBackground))
       : (renderer.themeMode ?? mode(RGBA.fromHex(bg)))
-    const theme = resolveTheme(generateSystem(colors, pick), pick)
+    const footerTheme = resolveTheme(generateSystem(colors, pick), pick)
     const indexed = indexedPalette(colors, 256)
+    const scrollbackTheme = quantizeTheme(footerTheme, indexed)
     const shared = await import("@opencode-ai/tui/context/theme")
     const syntaxTheme: SharedSyntaxTheme = {
-      ...theme,
+      ...scrollbackTheme,
       _hasSelectedListItemText: true,
     }
     const syntax = shared.generateSyntax(syntaxTheme)
-    return map(theme, splashTheme(theme, indexed), syntax, shared.generateSubtleSyntax(syntaxTheme))
+    return map(
+      footerTheme,
+      scrollbackTheme,
+      splashTheme(scrollbackTheme, indexed),
+      syntax,
+      shared.generateSubtleSyntax(syntaxTheme),
+    )
   } catch {
     return RUN_THEME_FALLBACK
   }

+ 47 - 0
packages/opencode/src/cli/cmd/run/turn-summary.ts

@@ -0,0 +1,47 @@
+import * as Locale from "@/util/locale"
+import type { SessionMessages } from "./session.shared"
+import type { RunProvider, StreamCommit } from "./types"
+
+export function turnSummaryCommit(input: {
+  agent: string
+  model: string
+  duration: string
+  messageID?: string
+}): StreamCommit {
+  return {
+    kind: "system",
+    text: `▣ ${input.agent} · ${input.model} · ${input.duration}`,
+    phase: "final",
+    source: "system",
+    summary: {
+      agent: input.agent,
+      model: input.model,
+      duration: input.duration,
+    },
+    messageID: input.messageID,
+  }
+}
+
+export function messageTurnSummaryCommit(
+  message: SessionMessages[number],
+  providers?: RunProvider[],
+): StreamCommit | undefined {
+  const info = message.info
+  if (info.role !== "assistant") {
+    return
+  }
+
+  const completed = info.time.completed
+  if (typeof completed !== "number" || completed <= info.time.created) {
+    return
+  }
+
+  const model = providers?.find((item) => item.id === info.providerID)?.models[info.modelID]?.name
+
+  return turnSummaryCommit({
+    agent: Locale.titlecase(info.agent),
+    model: model ?? info.modelID,
+    duration: Locale.duration(completed - info.time.created),
+    messageID: info.id,
+  })
+}

+ 9 - 1
packages/opencode/src/cli/cmd/run/types.ts

@@ -97,6 +97,12 @@ export type FooterPatch = Partial<FooterState>
 
 export type RunDiffStyle = "auto" | "stacked"
 
+export type TurnSummary = {
+  agent: string
+  model: string
+  duration: string
+}
+
 export type ScrollbackOptions = {
   diffStyle?: RunDiffStyle
   suppressBackgrounds?: boolean
@@ -175,6 +181,7 @@ export type FooterPromptRoute =
   | { type: "subagent-menu" }
   | { type: "subagent"; sessionID: string }
   | { type: "command" }
+  | { type: "skill" }
   | { type: "model" }
   | { type: "variant" }
 
@@ -184,7 +191,7 @@ export type FooterSubagentTab = {
   callID: string
   label: string
   description: string
-  status: "running" | "completed" | "error"
+  status: "running" | "completed" | "cancelled" | "error"
   background?: boolean
   title?: string
   toolCalls?: number
@@ -298,6 +305,7 @@ export type StreamCommit = {
   text: string
   phase: StreamPhase
   source: StreamSource
+  summary?: TurnSummary
   messageID?: string
   partID?: string
   tool?: string

+ 2 - 1
packages/opencode/src/util/process.ts

@@ -1,9 +1,10 @@
 import { type ChildProcess } from "child_process"
+import type { Stream } from "node:stream"
 import launch from "cross-spawn"
 import { buffer } from "node:stream/consumers"
 import { errorMessage } from "./error"
 
-export type Stdio = "inherit" | "pipe" | "ignore"
+export type Stdio = "inherit" | "pipe" | "ignore" | number | Stream
 export type Shell = boolean | string
 
 export interface Options {

+ 442 - 34
packages/opencode/test/cli/run/footer.view.test.tsx

@@ -12,6 +12,7 @@ import {
   RunCommandMenuBody,
   RunModelSelectBody,
   RunQueuedPromptSelectBody,
+  RunSkillSelectBody,
   RunSubagentSelectBody,
   RunVariantSelectBody,
 } from "@/cli/cmd/run/footer.command"
@@ -155,13 +156,23 @@ async function renderFooter(
     tuiConfig?: RunTuiConfig
     commands?: RunCommand[]
     theme?: () => RunTheme
+    providers?: RunProvider[]
+    currentModel?: RunInput["model"]
+    currentVariant?: string
+    subagents?: FooterSubagentState
+    backgroundSubagents?: boolean
+    width?: number
+    height?: number
+    state?: Partial<FooterState>
     onCycle?: () => void
     onSubmit?: (prompt: RunPrompt) => boolean
   } = {},
 ) {
   const [view] = createSignal<FooterView>({ type: "prompt" })
-  const [subagents] = createSignal<FooterSubagentState>({ tabs: [], details: {}, permissions: [], questions: [] })
-  const state = footerState()
+  const [subagents] = createSignal<FooterSubagentState>(
+    input.subagents ?? { tabs: [], details: {}, permissions: [], questions: [] },
+  )
+  const state = footerState(input.state)
   const config = input.tuiConfig ?? tuiConfig
   let offKeymap: (() => void) | undefined
 
@@ -178,16 +189,16 @@ async function renderFooter(
           agents={() => []}
           resources={() => []}
           commands={() => input.commands ?? []}
-          providers={() => undefined}
-          currentModel={() => undefined}
+          providers={() => input.providers}
+          currentModel={() => input.currentModel}
           variants={() => []}
-          currentVariant={() => undefined}
+          currentVariant={() => input.currentVariant}
           state={state}
           view={view}
           subagent={subagents}
           theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
           tuiConfig={config}
-          backgroundSubagents={true}
+          backgroundSubagents={input.backgroundSubagents ?? true}
           agent="opencode"
           onSubmit={input.onSubmit ?? (() => true)}
           onPermissionReply={() => {}}
@@ -195,6 +206,7 @@ async function renderFooter(
           onQuestionReject={() => {}}
           onCycle={input.onCycle ?? (() => {})}
           onInterrupt={() => false}
+          onEditorOpen={async () => undefined}
           onInputClear={() => {}}
           onExit={() => {}}
           onModelSelect={() => {}}
@@ -210,11 +222,11 @@ async function renderFooter(
 
   const app = await testRender(
     () => (
-      <box width={100} height={8}>
+      <box width={input.width ?? 100} height={input.height ?? 8}>
         <Harness />
       </box>
     ),
-    { width: 100, height: 8, kittyKeyboard: true },
+    { width: input.width ?? 100, height: input.height ?? 8, kittyKeyboard: true },
   )
 
   return {
@@ -229,7 +241,14 @@ async function renderFooter(
   }
 }
 
-test("direct footer updates composer background when theme changes", async () => {
+function expectPaletteList(list: BoxRenderable, selectedIndex: number) {
+  expect(list.backgroundColor.toInts()).toEqual((RUN_THEME_FALLBACK.footer.shade as RGBA).toInts())
+  expect((list.getChildren()[selectedIndex] as BoxRenderable).backgroundColor.toInts()).toEqual(
+    (RUN_THEME_FALLBACK.footer.selected as RGBA).toInts(),
+  )
+}
+
+test("direct footer composer area does not adopt footer surface", async () => {
   const surface = RGBA.fromHex("#123456")
   const [theme, setTheme] = createSignal(RUN_THEME_FALLBACK)
   const app = await renderFooter({ theme })
@@ -248,7 +267,7 @@ test("direct footer updates composer background when theme changes", async () =>
     })
     await app.renderOnce()
 
-    expect(area.backgroundColor.toInts()).toEqual(surface.toInts())
+    expect(area.backgroundColor.toInts()).not.toEqual(surface.toInts())
   } finally {
     app.cleanup()
   }
@@ -319,6 +338,8 @@ test("direct command panel renders grouped command palette", async () => {
           variantCycle="ctrl+t"
           onClose={() => {}}
           onModel={() => {}}
+          onEditor={() => {}}
+          onSkill={() => {}}
           onSubagent={() => {}}
           onQueued={() => {}}
           onVariant={() => {}}
@@ -341,17 +362,17 @@ test("direct command panel renders grouped command palette", async () => {
 
     expect(frame).toContain("Commands")
     expect(frame).toContain("Search")
-    expect(frame).toContain("Suggested")
-    expect(frame).toContain("Switch model")
-    expect(frame).toContain("Variant cycle")
-    expect(frame).toContain("ctrl+t")
-    expect(frame).toContain("Switch model variant")
     expect(frame).toContain("Session")
-    expect(frame).toContain("New session")
-    expect(frame).toContain("/new")
-    expect(frame).toContain("Project Commands")
-    expect(frame).toContain("review")
-    expect(frame).toContain("/review")
+    expect(frame).toContain("Agent")
+    expect(frame).toContain("Prompt")
+    expect(frame).toContain("Open editor")
+    expect(frame).toContain("/editor")
+    expect(frame).toContain("Switch model")
+    expect(frame).toContain("Skills")
+    expect(frame).toContain("/skills")
+    expect(frame.match(/\bAgent\b/g)?.length).toBe(1)
+    expect(frame).not.toContain("┌")
+    expect(frame).not.toContain("┃")
     expect(frame).not.toContain("/internal")
     expect(frame).not.toContain("Choose model for future turns")
     expect(frame).not.toContain("Cycle reasoning effort for future turns")
@@ -362,6 +383,85 @@ test("direct command panel renders grouped command palette", async () => {
   }
 })
 
+test("direct skill panel renders searchable skill list", async () => {
+  const [commands] = createSignal<RunCommand[] | undefined>([
+    command({ name: "review", description: "Review code" }),
+    command({ name: "internal", description: "Skill command", source: "skill" }),
+    command({ name: "formatter", description: "Apply formatter fixes", source: "skill" }),
+  ])
+
+  const app = await testRender(
+    () => (
+      <box width={100} height={RUN_COMMAND_PANEL_ROWS}>
+        <RunSkillSelectBody
+          theme={() => RUN_THEME_FALLBACK.footer}
+          commands={commands}
+          onClose={() => {}}
+          onSelect={() => {}}
+        />
+      </box>
+    ),
+    {
+      width: 100,
+      height: RUN_COMMAND_PANEL_ROWS,
+    },
+  )
+
+  try {
+    await app.renderOnce()
+    const frame = app.captureCharFrame()
+
+    expect(frame).toContain("Skills")
+    expect(frame).toContain("Search")
+    expect(frame).toContain("internal")
+    expect(frame).not.toContain("/internal")
+    expect(frame).toContain("formatter")
+    expect(frame).toContain("Apply formatter fixes")
+    expect(frame).not.toContain("review")
+  } finally {
+    app.renderer.destroy()
+  }
+})
+
+test("direct skill panel truncates long descriptions from the end", async () => {
+  const [commands] = createSignal<RunCommand[] | undefined>([
+    command({
+      name: "terminal-control",
+      description:
+        "Control and test terminal applications, REPLs, interactive CLIs, shell processes, OpenTUI applications, or other terminal-backed workflows.",
+      source: "skill",
+    }),
+  ])
+
+  const app = await testRender(
+    () => (
+      <box width={100} height={RUN_COMMAND_PANEL_ROWS}>
+        <RunSkillSelectBody
+          theme={() => RUN_THEME_FALLBACK.footer}
+          commands={commands}
+          onClose={() => {}}
+          onSelect={() => {}}
+        />
+      </box>
+    ),
+    {
+      width: 100,
+      height: RUN_COMMAND_PANEL_ROWS,
+    },
+  )
+
+  try {
+    await app.renderOnce()
+    const frame = app.captureCharFrame()
+
+    expect(frame).toContain("terminal-control")
+    expect(frame).toContain("Control and test terminal applications")
+    expect(frame).not.toMatch(/application(?:…|\.\.\.)ocess/)
+  } finally {
+    app.renderer.destroy()
+  }
+})
+
 test("direct command panel shows subagent entry when available", async () => {
   const [commands] = createSignal<RunCommand[] | undefined>([])
   const [subagents] = createSignal([subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })])
@@ -379,6 +479,8 @@ test("direct command panel shows subagent entry when available", async () => {
           variantCycle="ctrl+t"
           onClose={() => {}}
           onModel={() => {}}
+          onEditor={() => {}}
+          onSkill={() => {}}
           onSubagent={() => {}}
           onQueued={() => {}}
           onVariant={() => {}}
@@ -406,6 +508,54 @@ test("direct command panel shows subagent entry when available", async () => {
   }
 })
 
+test("direct command panel keeps completed subagents available", async () => {
+  const [commands] = createSignal<RunCommand[] | undefined>([])
+  const [subagents] = createSignal([
+    subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow", status: "completed" }),
+  ])
+  const [variants] = createSignal<string[]>([])
+
+  const app = await testRender(
+    () => (
+      <box width={100} height={RUN_COMMAND_PANEL_ROWS}>
+        <RunCommandMenuBody
+          theme={() => RUN_THEME_FALLBACK.footer}
+          commands={commands}
+          subagents={subagents}
+          queued={() => []}
+          variants={variants}
+          variantCycle="ctrl+t"
+          onClose={() => {}}
+          onModel={() => {}}
+          onEditor={() => {}}
+          onSkill={() => {}}
+          onSubagent={() => {}}
+          onQueued={() => {}}
+          onVariant={() => {}}
+          onVariantCycle={() => {}}
+          onCommand={() => {}}
+          onNew={() => {}}
+          onExit={() => {}}
+        />
+      </box>
+    ),
+    {
+      width: 100,
+      height: RUN_COMMAND_PANEL_ROWS,
+    },
+  )
+
+  try {
+    await app.renderOnce()
+    const frame = app.captureCharFrame()
+
+    expect(frame).toContain("View subagents")
+    expect(frame).toContain("1 recent")
+  } finally {
+    app.renderer.destroy()
+  }
+})
+
 test("direct subagent panel renders active subagents", async () => {
   const [tabs] = createSignal([
     subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" }),
@@ -438,11 +588,15 @@ test("direct subagent panel renders active subagents", async () => {
   try {
     await app.renderOnce()
     const frame = app.captureCharFrame()
+    const list = app.renderer.root.findDescendantById("run-direct-footer-subagent-list") as BoxRenderable
 
     expect(frame).toContain("Select subagent")
     expect(frame).toContain("Inspect auth flow")
     expect(frame).toContain("Write migration plan")
     expect(frame).toContain("done")
+    expect(frame).not.toContain("┌")
+    expect(frame).not.toContain("┃")
+    expectPaletteList(list, 0)
     expect(rows).toBe(8)
   } finally {
     app.renderer.destroy()
@@ -471,25 +625,41 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
 
   try {
     await app.renderOnce()
-    expect(app.captureCharFrame()).toContain("Queued prompts")
-    expect(app.captureCharFrame()).toContain("fix the auth test")
-    expect(app.captureCharFrame()).toContain("queued")
+    const frame = app.captureCharFrame()
+    const list = app.renderer.root.findDescendantById("run-direct-footer-queued-list") as BoxRenderable
+
+    expect(frame).toContain("Queued prompts")
+    expect(frame).toContain("fix the auth test")
+    expect(frame).toContain("queued")
+    expect(frame).not.toContain("┌")
+    expect(frame).not.toContain("┃")
+    expectPaletteList(list, 0)
   } finally {
     app.renderer.destroy()
   }
 })
 
-// OpenTUI currently segfaults when the full footer view suite creates several
-// keymap-backed test renderers in one process. Re-enable after the runtime fix.
-test.skip("direct footer opens command panel through keymap binding", async () => {
+// OpenTUI currently crashes Bun in the full `test/cli/run` directory run here.
+// Re-enable after the upstream OpenTUI fix lands in this repo.
+test.skip("direct footer recreates the frame across command panel transitions", async () => {
   const app = await renderFooter()
 
   try {
     await app.renderOnce()
-    app.mockInput.pressKey("p", { ctrl: true })
-    await app.renderOnce()
 
-    expect(app.captureCharFrame()).toContain("Commands")
+    for (let index = 0; index < 3; index++) {
+      const composerFrame = app.renderer.root.findDescendantById("run-direct-footer-composer-frame") as BoxRenderable
+      app.mockInput.pressKey("p", { ctrl: true })
+      await app.renderOnce()
+
+      expect(app.captureCharFrame()).toContain("Commands")
+      expect(app.renderer.root.findDescendantById("run-direct-footer-composer-frame")).not.toBe(composerFrame)
+      app.mockInput.pressKey("c", { ctrl: true })
+      await app.renderOnce()
+      expect(app.captureCharFrame()).not.toContain("Commands")
+      expect(app.captureCharFrame()).not.toContain("┃")
+      expect(app.captureCharFrame()).not.toContain("█")
+    }
   } finally {
     app.cleanup()
   }
@@ -596,6 +766,104 @@ test("direct footer submits slash autocomplete selections without dispatching sh
   }
 })
 
+test("direct footer slash autocomplete keeps a real skills command", async () => {
+  const submits: RunPrompt[] = []
+  const app = await renderFooter({
+    commands: [
+      command({ name: "skills", description: "Run the real skills command" }),
+      command({ name: "formatter", description: "Apply formatter fixes", source: "skill" }),
+    ],
+    onSubmit(prompt) {
+      submits.push(prompt)
+      return true
+    },
+  })
+
+  try {
+    await app.renderOnce()
+    "/skills".split("").forEach((key) => app.mockInput.pressKey(key))
+    await app.renderOnce()
+    app.mockInput.pressEnter()
+    await app.renderOnce()
+
+    expect(submits).toEqual([{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" } }])
+    expect(app.captureCharFrame()).not.toContain("Apply formatter fixes")
+  } finally {
+    app.cleanup()
+  }
+})
+
+// OpenTUI currently segfaults Bun while tearing down this composer-to-skill-panel transition.
+// Re-enable after the upstream renderer teardown fix lands.
+test.skip("direct footer skill picker inserts an editable bound skill command", async () => {
+  const submits: RunPrompt[] = []
+  const app = await renderFooter({
+    commands: [command({ name: "new", description: "Skill named new", source: "skill" })],
+    onSubmit(prompt) {
+      submits.push(prompt)
+      return true
+    },
+  })
+
+  try {
+    await app.renderOnce()
+    "/skills".split("").forEach((key) => app.mockInput.pressKey(key))
+    await app.renderOnce()
+    app.mockInput.pressEnter()
+    await app.renderOnce()
+
+    expect(app.captureCharFrame()).toContain("Skill named new")
+
+    app.mockInput.pressEnter()
+    await app.renderOnce()
+
+    expect(submits).toEqual([])
+    expect(app.captureCharFrame()).toContain("/new")
+
+    "task".split("").forEach((key) => app.mockInput.pressKey(key))
+    await app.renderOnce()
+    app.mockInput.pressEnter()
+    await app.renderOnce()
+
+    expect(submits).toEqual([{ text: "/new task", parts: [], command: { name: "new", arguments: "task" } }])
+  } finally {
+    app.cleanup()
+  }
+})
+
+// OpenTUI currently segfaults Bun while tearing down this skill-panel close transition.
+// Re-enable after the upstream renderer teardown fix lands.
+test.skip("direct footer clears the synthetic skills draft when the panel closes", async () => {
+  const submits: RunPrompt[] = []
+  const app = await renderFooter({
+    commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })],
+    onSubmit(prompt) {
+      submits.push(prompt)
+      return true
+    },
+  })
+
+  try {
+    await app.renderOnce()
+    "/skills".split("").forEach((key) => app.mockInput.pressKey(key))
+    await app.renderOnce()
+    app.mockInput.pressEnter()
+    await app.renderOnce()
+
+    expect(app.captureCharFrame()).toContain("Apply formatter fixes")
+
+    app.mockInput.pressKey("c", { ctrl: true })
+    await app.renderOnce()
+    app.mockInput.pressEnter()
+    await app.renderOnce()
+
+    expect(submits).toEqual([])
+    expect(app.captureCharFrame()).not.toContain("/skills")
+  } finally {
+    app.cleanup()
+  }
+})
+
 test("direct footer shows editable prompts and additional queued work while running", async () => {
   const [state] = createSignal<FooterState>({
     phase: "running",
@@ -630,7 +898,10 @@ test("direct footer shows editable prompts and additional queued work while runn
           resources={() => []}
           commands={() => []}
           providers={() => undefined}
-          currentModel={() => undefined}
+          currentModel={() => ({
+            providerID: "opencode",
+            modelID: "a-model-name-long-enough-to-force-responsive-truncation",
+          })}
           variants={() => []}
           currentVariant={() => undefined}
           state={state}
@@ -649,6 +920,7 @@ test("direct footer shows editable prompts and additional queued work while runn
           onQuestionReject={() => {}}
           onCycle={() => {}}
           onInterrupt={() => false}
+          onEditorOpen={async () => undefined}
           onInputClear={() => {}}
           onExit={() => {}}
           onModelSelect={() => {}}
@@ -676,10 +948,34 @@ test("direct footer shows editable prompts and additional queued work while runn
 
   try {
     await app.renderOnce()
-    expect(app.captureCharFrame()).toContain("interrupt • 1 agent ctrl+x down • ctrl+b background • 1 queued ctrl+x q")
-    expect(app.captureCharFrame()).toContain("2 queued")
-    expect(app.captureCharFrame()).not.toContain("to view")
-    expect(app.captureCharFrame()).not.toContain("edit/remove")
+    const frame = app.captureCharFrame()
+    const transparent = RGBA.fromValues(0, 0, 0, 0).toInts()
+    const tinted = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
+    const accent = (RUN_THEME_FALLBACK.footer.statusAccent as RGBA).toInts()
+    const statusline = app.renderer.root.findDescendantById("run-direct-footer-statusline") as BoxRenderable
+    const mode = app.renderer.root.findDescendantById("run-direct-footer-statusline-mode") as BoxRenderable
+    const main = app.renderer.root.findDescendantById("run-direct-footer-statusline-main") as BoxRenderable
+    const spinner = app.renderer.root.findDescendantById("run-direct-footer-status-spinner")
+    const model = app.renderer.root.findDescendantById("run-direct-footer-statusline-model") as BoxRenderable
+    const queued = app.renderer.root.findDescendantById("run-direct-footer-statusline-queued") as BoxRenderable
+    const hint = app.renderer.root.findDescendantById("run-direct-footer-statusline-hint") as BoxRenderable
+
+    expect(spinner).toBeDefined()
+    expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
+    expect(frame).toContain("3 queued")
+    expect(frame).toContain("ctrl+b background")
+    expect(frame).toContain("ctrl+x q 3 queued")
+    expect(frame).toContain("ctrl+x down subagents")
+    expect(frame).toContain("ctrl+p cmd")
+    expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
+    expect(frame).toContain("subagents · ctrl+p cmd")
+    expect(frame).not.toContain("1 agent")
+    expect(statusline.backgroundColor.toInts()).toEqual(tinted)
+    expect(mode.backgroundColor.toInts()).toEqual(accent)
+    expect(main.backgroundColor.toInts()).toEqual(transparent)
+    expect(model.backgroundColor.toInts()).toEqual(transparent)
+    expect(queued.backgroundColor.toInts()).toEqual(transparent)
+    expect(hint.backgroundColor.toInts()).toEqual(transparent)
   } finally {
     app.renderer.currentFocusedRenderable?.blur()
     app.renderer.currentFocusedEditor?.blur()
@@ -688,6 +984,110 @@ test("direct footer shows editable prompts and additional queued work while runn
   }
 })
 
+test("direct footer separates a lone context hint from model and command hint", async () => {
+  const app = await renderFooter({
+    providers: [provider()],
+    currentModel: { providerID: "opencode", modelID: "gpt-5" },
+    currentVariant: "xhigh",
+    subagents: {
+      tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })],
+      details: {},
+      permissions: [],
+      questions: [],
+    },
+    backgroundSubagents: false,
+    width: 160,
+  })
+
+  try {
+    await app.renderOnce()
+    const frame = app.captureCharFrame()
+
+    expect(frame).toContain("GPT-5")
+    expect(frame).toContain("xhigh · ctrl+x down subagents · ctrl+p cmd")
+    expect(frame).not.toContain("ctrl+b background")
+    expect(frame).not.toContain("queued")
+  } finally {
+    app.cleanup()
+  }
+})
+
+test("direct footer hides the subagent hint when only completed subagents remain", async () => {
+  const app = await renderFooter({
+    providers: [provider()],
+    currentModel: { providerID: "opencode", modelID: "gpt-5" },
+    currentVariant: "xhigh",
+    subagents: {
+      tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow", status: "completed" })],
+      details: {},
+      permissions: [],
+      questions: [],
+    },
+    backgroundSubagents: false,
+    width: 160,
+  })
+
+  try {
+    await app.renderOnce()
+    const frame = app.captureCharFrame()
+
+    expect(frame).toContain("GPT-5")
+    expect(frame).toContain("xhigh · ctrl+p cmd")
+    expect(frame).not.toContain("ctrl+x down subagents")
+  } finally {
+    app.cleanup()
+  }
+})
+
+test("direct footer omits interrupt key hint when interrupt is unbound", async () => {
+  const app = await renderFooter({
+    tuiConfig: createTuiResolvedConfig({ keybinds: { session_interrupt: "none", input_clear: "ctrl+l" } }),
+    state: { phase: "running" },
+  })
+
+  try {
+    await app.renderOnce()
+    const frame = app.captureCharFrame()
+
+    expect(frame).toContain("interrupt")
+    expect(frame).not.toContain("ctrl+l")
+  } finally {
+    app.cleanup()
+  }
+})
+
+test("direct footer shows full usage metadata when room is available", async () => {
+  const app = await renderFooter({
+    state: { usage: "159.6K (16%) · $4.23" },
+  })
+
+  try {
+    await app.renderOnce()
+    const frame = app.captureCharFrame()
+
+    expect(frame).toContain("159.6K (16%) · $4.23")
+  } finally {
+    app.cleanup()
+  }
+})
+
+test("direct footer mode label keeps left padding without a status pill", async () => {
+  const app = await renderFooter()
+
+  try {
+    await app.renderOnce()
+    const statusline = app
+      .captureCharFrame()
+      .split("\n")
+      .find((line) => line.includes("BUILD") && line.includes("cmd"))
+
+    expect(statusline).toBeDefined()
+    expect(statusline?.startsWith(" BUILD ")).toBe(true)
+  } finally {
+    app.cleanup()
+  }
+})
+
 test("direct question body separates single-select checkmark from label", async () => {
   const request = {
     id: "question-1",
@@ -876,6 +1276,7 @@ test("direct model panel renders current model selector", async () => {
   try {
     await app.renderOnce()
     const frame = app.captureCharFrame()
+    const list = app.renderer.root.findDescendantById("run-direct-footer-model-list") as BoxRenderable
 
     expect(frame).toContain("Select model")
     expect(frame).toContain("Search")
@@ -884,7 +1285,10 @@ test("direct model panel renders current model selector", async () => {
     expect(frame).toContain("current")
     expect(frame).toContain("GPT Free")
     expect(frame).toContain("Free")
+    expect(frame).not.toContain("┌")
+    expect(frame).not.toContain("┃")
     expect(frame).not.toContain("Old Model")
+    expectPaletteList(list, 2)
   } finally {
     app.renderer.destroy()
   }
@@ -915,12 +1319,16 @@ test("direct variant panel renders current variant selector", async () => {
   try {
     await app.renderOnce()
     const frame = app.captureCharFrame()
+    const list = app.renderer.root.findDescendantById("run-direct-footer-variant-list") as BoxRenderable
 
     expect(frame).toContain("Select variant")
     expect(frame).toContain("Default")
     expect(frame).toContain("high")
     expect(frame).toContain("minimal")
     expect(frame).toContain("current")
+    expect(frame).not.toContain("┌")
+    expect(frame).not.toContain("┃")
+    expectPaletteList(list, 1)
   } finally {
     app.renderer.destroy()
   }

+ 35 - 0
packages/opencode/test/cli/run/footer.width.test.ts

@@ -0,0 +1,35 @@
+import { describe, expect, test } from "bun:test"
+import { footerWidthPolicy } from "@/cli/cmd/run/footer.width"
+
+describe("run footer width", () => {
+  test("preserves shared dialog and statusline breakpoints", () => {
+    const narrow = footerWidthPolicy(79)
+    expect(narrow.dialog.narrow).toBe(true)
+    expect(narrow.statusline.showActivityMeta).toBe(false)
+    expect(narrow.statusline.showCommandHint).toBe(true)
+    expect(narrow.statusline.showContextHints).toBe(false)
+    expect(narrow.statusline.contextHintLimit).toBe(0)
+    expect(narrow.statusline.showModel).toBe(false)
+
+    const command = footerWidthPolicy(65)
+    expect(command.statusline.showCommandHint).toBe(false)
+
+    const commandHint = footerWidthPolicy(66)
+    expect(commandHint.statusline.showCommandHint).toBe(true)
+
+    const compact = footerWidthPolicy(80)
+    expect(compact.dialog.narrow).toBe(false)
+    expect(compact.statusline.showActivityMeta).toBe(true)
+    expect(compact.statusline.showContextHints).toBe(true)
+    expect(compact.statusline.contextHintLimit).toBe(1)
+    expect(compact.statusline.showModel).toBe(false)
+
+    const model = footerWidthPolicy(120)
+    expect(model.statusline.contextHintLimit).toBe(2)
+    expect(model.statusline.showModel).toBe(true)
+
+    const spacious = footerWidthPolicy(150)
+    expect(spacious.statusline.contextHintLimit).toBeUndefined()
+    expect(spacious.statusline.showModel).toBe(true)
+  })
+})

+ 101 - 0
packages/opencode/test/cli/run/prompt.editor.test.ts

@@ -0,0 +1,101 @@
+import { describe, expect, test } from "bun:test"
+import { realignEditorPromptParts, resolveEditorSlashValue } from "@/cli/cmd/run/prompt.editor"
+import type { RunPromptPart } from "@/cli/cmd/run/types"
+
+describe("run prompt editor helpers", () => {
+  test("strips the local /editor command from the initial editor text", () => {
+    expect(resolveEditorSlashValue("/editor")).toBe("")
+    expect(resolveEditorSlashValue("/editor draft message")).toBe("draft message")
+    expect(resolveEditorSlashValue("/editor first line\nsecond line")).toBe("first line\nsecond line")
+  })
+
+  test("realigns file and agent parts after external editing", () => {
+    const filePart = {
+      type: "file",
+      mime: "text/plain",
+      filename: "src/app.ts",
+      url: "file:///src/app.ts",
+      source: {
+        type: "file",
+        path: "src/app.ts",
+        text: {
+          start: 0,
+          end: 11,
+          value: "@src/app.ts",
+        },
+      },
+    } satisfies RunPromptPart
+    const agentPart = {
+      type: "agent",
+      name: "helper",
+      source: {
+        start: 12,
+        end: 19,
+        value: "@helper",
+      },
+    } satisfies RunPromptPart
+    const parts = [filePart, agentPart]
+
+    expect(realignEditorPromptParts("Please check @helper before @src/app.ts", parts)).toEqual([
+      {
+        ...filePart,
+        source: {
+          ...filePart.source,
+          text: {
+            ...filePart.source.text,
+            start: 28,
+            end: 39,
+            value: "@src/app.ts",
+          },
+        },
+      },
+      {
+        ...agentPart,
+        source: {
+          start: 13,
+          end: 20,
+          value: "@helper",
+        },
+      },
+    ])
+  })
+
+  test("drops parts whose virtual text was deleted", () => {
+    const filePart = {
+      type: "file",
+      mime: "text/plain",
+      filename: "src/app.ts",
+      url: "file:///src/app.ts",
+      source: {
+        type: "file",
+        path: "src/app.ts",
+        text: {
+          start: 0,
+          end: 11,
+          value: "@src/app.ts",
+        },
+      },
+    } satisfies RunPromptPart
+    const agentPart = {
+      type: "agent",
+      name: "helper",
+      source: {
+        start: 12,
+        end: 19,
+        value: "@helper",
+      },
+    } satisfies RunPromptPart
+    const parts = [filePart, agentPart]
+
+    expect(realignEditorPromptParts("Only @helper remains", parts)).toEqual([
+      {
+        ...agentPart,
+        source: {
+          start: 5,
+          end: 12,
+          value: "@helper",
+        },
+      },
+    ])
+  })
+})

+ 16 - 0
packages/opencode/test/cli/run/runtime.queue.test.ts

@@ -206,6 +206,22 @@ describe("run runtime queue", () => {
     await task
   })
 
+  test("shell mode does not emit a turn duration summary", async () => {
+    const ui = footer()
+
+    const task = runPromptQueue({
+      footer: ui.api,
+      run: async () => {
+        ui.api.close()
+      },
+    })
+
+    ui.submit("ls", "shell")
+    await task
+
+    expect(ui.events.some((event) => event.type === "turn.duration")).toBe(false)
+  })
+
   test("preserves whitespace for initial input", async () => {
     const ui = footer()
     const seen: string[] = []

+ 238 - 0
packages/opencode/test/cli/run/runtime.test.ts

@@ -0,0 +1,238 @@
+import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
+import { OpencodeClient } from "@opencode-ai/sdk/v2"
+import { runInteractiveMode } from "@/cli/cmd/run/runtime"
+import type { FooterApi, RunProvider } from "@/cli/cmd/run/types"
+
+type SessionMessage = NonNullable<Awaited<ReturnType<OpencodeClient["session"]["messages"]>>["data"]>[number]
+
+const provider: RunProvider = {
+  id: "openai",
+  name: "OpenAI",
+  source: "api",
+  env: [],
+  options: {},
+  models: {
+    "gpt-5": {
+      id: "gpt-5",
+      providerID: "openai",
+      api: {
+        id: "openai",
+        url: "https://openai.test",
+        npm: "@ai-sdk/openai",
+      },
+      name: "Little Frank",
+      capabilities: {
+        temperature: true,
+        reasoning: true,
+        attachment: true,
+        toolcall: true,
+        input: {
+          text: true,
+          audio: false,
+          image: false,
+          video: false,
+          pdf: false,
+        },
+        output: {
+          text: true,
+          audio: false,
+          image: false,
+          video: false,
+          pdf: false,
+        },
+        interleaved: false,
+      },
+      cost: {
+        input: 0,
+        output: 0,
+        cache: {
+          read: 0,
+          write: 0,
+        },
+      },
+      limit: {
+        context: 128000,
+        output: 8192,
+      },
+      status: "active",
+      options: {},
+      headers: {},
+      release_date: "2026-01-01",
+    },
+  },
+}
+
+const transportProviders: RunProvider[][] = []
+
+function defer<T>() {
+  let resolve!: (value: T | PromiseLike<T>) => void
+  const promise = new Promise<T>((done) => {
+    resolve = done
+  })
+  return { promise, resolve }
+}
+
+function ok<T>(data: T) {
+  return Promise.resolve({
+    data,
+    error: undefined,
+    request: new Request("https://opencode.test"),
+    response: new Response(),
+  })
+}
+
+function footer(): FooterApi {
+  let closed = false
+  const closes = new Set<() => void>()
+
+  const notify = () => {
+    for (const fn of closes) fn()
+  }
+
+  return {
+    get isClosed() {
+      return closed
+    },
+    onPrompt: () => () => {},
+    onQueuedRemove: () => () => {},
+    onClose(fn) {
+      if (closed) {
+        fn()
+        return () => {}
+      }
+
+      closes.add(fn)
+      return () => {
+        closes.delete(fn)
+      }
+    },
+    event() {},
+    append() {},
+    idle() {
+      return Promise.resolve()
+    },
+    close() {
+      if (closed) {
+        return
+      }
+
+      closed = true
+      notify()
+    },
+    destroy() {
+      if (closed) {
+        return
+      }
+
+      closed = true
+      notify()
+    },
+  }
+}
+
+afterEach(() => {
+  mock.restore()
+  transportProviders.length = 0
+})
+
+describe("run interactive runtime", () => {
+  test("waits for provider metadata before eager replay transport bootstrap", async () => {
+    const providersStarted = defer<void>()
+    const providers = defer<void>()
+
+    const sdk = new OpencodeClient()
+    spyOn(sdk.config, "providers").mockImplementation(async () => {
+      providersStarted.resolve()
+      await providers.promise
+      return ok({ providers: [provider], default: {} })
+    })
+    spyOn(sdk.session, "messages").mockImplementation(() =>
+      ok([
+        {
+          info: {
+            id: "msg-user-1",
+            sessionID: "ses-1",
+            role: "user",
+            time: {
+              created: 1,
+            },
+            agent: "build",
+            model: {
+              providerID: "openai",
+              modelID: "gpt-5",
+              variant: undefined,
+            },
+          },
+          parts: [
+            {
+              id: "part-user-1",
+              sessionID: "ses-1",
+              messageID: "msg-user-1",
+              type: "text",
+              text: "hello",
+            },
+          ],
+        } satisfies SessionMessage,
+      ]),
+    )
+    spyOn(sdk.session, "get").mockRejectedValue(new Error("not needed"))
+    spyOn(sdk.app, "agents").mockImplementation(() => ok([]))
+    spyOn(sdk.experimental.resource, "list").mockImplementation(() => ok({}))
+    spyOn(sdk.command, "list").mockImplementation(() => ok([]))
+
+    const task = runInteractiveMode(
+      {
+        sdk,
+        directory: "/tmp",
+        sessionID: "ses-1",
+        sessionTitle: "Session",
+        resume: true,
+        replay: true,
+        replayLimit: 100,
+        agent: "build",
+        model: {
+          providerID: "openai",
+          modelID: "gpt-5",
+        },
+        variant: undefined,
+        files: [],
+        thinking: true,
+        backgroundSubagents: false,
+      },
+      {
+        createRuntimeLifecycle: async () => ({
+          footer: footer(),
+          onResize: () => () => {},
+          refreshTheme: () => {},
+          resetForReplay: () => Promise.resolve(),
+          close: () => Promise.resolve(),
+        }),
+        streamTransport: Promise.resolve({
+          createSessionTransport: async (input: { providers?: () => RunProvider[]; footer: FooterApi }) => {
+            transportProviders.push(input.providers?.() ?? [])
+            setTimeout(() => {
+              input.footer.close()
+            }, 0)
+            return {
+              runPromptTurn: async () => {},
+              selectSubagent: () => {},
+              replayOnResize: async () => false,
+              close: async () => {},
+            }
+          },
+          formatUnknownError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
+        }),
+      },
+    )
+
+    await providersStarted.promise
+
+    expect(transportProviders).toEqual([])
+
+    providers.resolve()
+
+    await task
+
+    expect(transportProviders).toEqual([[provider]])
+  })
+})

+ 17 - 0
packages/opencode/test/cli/run/scrollback.surface.test.ts

@@ -111,6 +111,23 @@ function reasoning(text: string, phase: StreamCommit["phase"] = "progress"): Str
   }
 }
 
+test("turn summary starts at the left edge", async () => {
+  const out = await setup()
+
+  try {
+    await out.scrollback.writeTurnSummary({ agent: "Build", model: "Little Frank", duration: "2.2s" })
+
+    const commits = claim(out.renderer)
+    try {
+      expect(renderRows(commits.at(-1)!)[0]).toBe("▣ Build · Little Frank · 2.2s")
+    } finally {
+      destroy(commits)
+    }
+  } finally {
+    out.scrollback.destroy()
+  }
+})
+
 test("theme swaps restyle active reasoning without resetting the stream", async () => {
   const previousSyntax = SyntaxStyle.fromStyles({ default: { fg: "#123456" } })
   const nextSyntax = SyntaxStyle.fromStyles({ default: { fg: "#abcdef" } })

+ 249 - 12
packages/opencode/test/cli/run/session-replay.test.ts

@@ -1,6 +1,7 @@
 import { describe, expect, test } from "bun:test"
 import { replayLocalRows, replaySession } from "@/cli/cmd/run/session-replay"
 import type { SessionMessages } from "@/cli/cmd/run/session.shared"
+import type { RunProvider } from "@/cli/cmd/run/types"
 
 function userMessage(id: string, text: string): SessionMessages[number] {
   return {
@@ -29,17 +30,23 @@ function userMessage(id: string, text: string): SessionMessages[number] {
   }
 }
 
-function assistantInfo(id: string) {
+function assistantInfo(
+  id: string,
+  input: {
+    parentID?: string
+    modelID?: string
+    providerID?: string
+    time?: { created: number; completed?: number }
+  } = {},
+) {
   return {
     id,
     sessionID: "session-1",
     role: "assistant" as const,
-    time: {
-      created: 2,
-    },
-    parentID: "msg-user-1",
-    modelID: "gpt-5",
-    providerID: "openai",
+    time: input.time ?? { created: 2 },
+    parentID: input.parentID ?? "msg-user-1",
+    modelID: input.modelID ?? "gpt-5",
+    providerID: input.providerID ?? "openai",
     mode: "chat",
     agent: "build",
     path: {
@@ -59,9 +66,26 @@ function assistantInfo(id: string) {
   }
 }
 
-function assistantMessage(id: string, text: string): SessionMessages[number] {
+function assistantMessage(
+  id: string,
+  text: string,
+  input: {
+    parentID?: string
+    modelID?: string
+    providerID?: string
+    time?: { created: number; completed?: number }
+  } = {},
+): SessionMessages[number] {
+  const time = input.time ?? {
+    created: 200,
+    completed: 3000,
+  }
+
   return {
-    info: assistantInfo(id),
+    info: assistantInfo(id, {
+      ...input,
+      time,
+    }),
     parts: [
       {
         id: `${id}-text`,
@@ -70,14 +94,72 @@ function assistantMessage(id: string, text: string): SessionMessages[number] {
         type: "text",
         text,
         time: {
-          start: 2,
-          end: 3,
+          start: time.created,
+          end: time.completed,
         },
       },
     ],
   }
 }
 
+const provider = (name: string): RunProvider =>
+  ({
+    id: "openai",
+    name: "OpenAI",
+    source: "api",
+    env: [],
+    options: {},
+    models: {
+      "gpt-5": {
+        id: "gpt-5",
+        providerID: "openai",
+        api: {
+          id: "openai",
+          url: "https://openai.test",
+          npm: "@ai-sdk/openai",
+        },
+        name,
+        capabilities: {
+          temperature: true,
+          reasoning: true,
+          attachment: true,
+          toolcall: true,
+          input: {
+            text: true,
+            audio: false,
+            image: false,
+            video: false,
+            pdf: false,
+          },
+          output: {
+            text: true,
+            audio: false,
+            image: false,
+            video: false,
+            pdf: false,
+          },
+          interleaved: false,
+        },
+        cost: {
+          input: 0,
+          output: 0,
+          cache: {
+            read: 0,
+            write: 0,
+          },
+        },
+        limit: {
+          context: 128000,
+          output: 8192,
+        },
+        status: "active",
+        options: {},
+        headers: {},
+        release_date: "2026-01-01",
+      },
+    },
+  })
+
 function runningToolMessage(id: string): SessionMessages[number] {
   return {
     info: assistantInfo(id),
@@ -103,8 +185,74 @@ function runningToolMessage(id: string): SessionMessages[number] {
   }
 }
 
+function shellUserMessage(id: string): SessionMessages[number] {
+  return {
+    info: {
+      id,
+      sessionID: "session-1",
+      role: "user",
+      time: {
+        created: 1,
+      },
+      agent: "build",
+      model: {
+        providerID: "openai",
+        modelID: "gpt-5",
+      },
+    },
+    parts: [
+      {
+        id: `${id}-text`,
+        sessionID: "session-1",
+        messageID: id,
+        type: "text",
+        text: "The following tool was executed by the user",
+        synthetic: true,
+      },
+    ],
+  }
+}
+
+function shellAssistantMessage(id: string, parentID: string): SessionMessages[number] {
+  return {
+    info: assistantInfo(id, {
+      parentID,
+      time: {
+        created: 200,
+        completed: 3000,
+      },
+    }),
+    parts: [
+      {
+        id: `${id}-tool`,
+        sessionID: "session-1",
+        messageID: id,
+        type: "tool",
+        callID: `${id}-call`,
+        tool: "bash",
+        state: {
+          status: "completed",
+          input: {
+            command: "ls",
+          },
+          output: "account.ts\n",
+          title: "",
+          metadata: {
+            output: "account.ts\n",
+            description: "",
+          },
+          time: {
+            start: 200,
+            end: 3000,
+          },
+        },
+      },
+    ],
+  }
+}
+
 describe("run session replay", () => {
-  test("replays persisted user and assistant history into scrollback commits", () => {
+  test("replays persisted user, assistant, and turn summary history into scrollback commits", () => {
     const out = replaySession({
       messages: [
         userMessage("msg-user-1", "Hello, whats the weather today?"),
@@ -131,6 +279,18 @@ describe("run session replay", () => {
         source: "assistant",
         messageID: "msg-1",
       }),
+      expect.objectContaining({
+        kind: "system",
+        text: "▣ Build · gpt-5 · 2.8s",
+        phase: "final",
+        source: "system",
+        messageID: "msg-1",
+        summary: {
+          agent: "Build",
+          model: "gpt-5",
+          duration: "2.8s",
+        },
+      }),
     ])
     expect(out.patch).toEqual(
       expect.objectContaining({
@@ -140,6 +300,60 @@ describe("run session replay", () => {
     )
   })
 
+  test("uses provider model names for replayed turn summaries when available", () => {
+    const out = replaySession({
+      messages: [
+        userMessage("msg-user-1", "Hello, whats the weather today?"),
+        assistantMessage("msg-1", "What city or ZIP code should I check?"),
+      ],
+      permissions: [],
+      questions: [],
+      thinking: true,
+      limits: {},
+      providers: [provider("Little Frank")],
+    })
+
+    expect(out.commits.at(-1)).toEqual(
+      expect.objectContaining({
+        kind: "system",
+        text: "▣ Build · Little Frank · 2.8s",
+        summary: {
+          agent: "Build",
+          model: "Little Frank",
+          duration: "2.8s",
+        },
+      }),
+    )
+  })
+
+  test("replays one turn summary for the final assistant in a multi-step turn", () => {
+    const out = replaySession({
+      messages: [
+        userMessage("msg-user-1", "Plan and then answer"),
+        assistantMessage("msg-step-1", "Working", {
+          parentID: "msg-user-1",
+          time: { created: 200, completed: 900 },
+        }),
+        assistantMessage("msg-step-2", "Done", {
+          parentID: "msg-user-1",
+          time: { created: 1000, completed: 3000 },
+        }),
+      ],
+      permissions: [],
+      questions: [],
+      thinking: true,
+      limits: {},
+    })
+
+    expect(out.commits.filter((commit) => commit.summary)).toEqual([
+      expect.objectContaining({
+        kind: "system",
+        text: "▣ Build · gpt-5 · 2.0s",
+        messageID: "msg-step-2",
+      }),
+    ])
+  })
+
   test("keeps the footer in a running state for resumed active tools", () => {
     const out = replaySession({
       messages: [runningToolMessage("msg-1")],
@@ -157,6 +371,29 @@ describe("run session replay", () => {
     )
   })
 
+  test("does not replay turn summaries for shell-mode commands", () => {
+    const out = replaySession({
+      messages: [
+        shellUserMessage("msg-shell-user-1"),
+        shellAssistantMessage("msg-shell-assistant-1", "msg-shell-user-1"),
+      ],
+      permissions: [],
+      questions: [],
+      thinking: true,
+      limits: {},
+    })
+
+    expect(out.commits.some((commit) => commit.summary)).toBe(false)
+    expect(out.commits).toContainEqual(
+      expect.objectContaining({
+        kind: "tool",
+        text: "account.ts\n",
+        tool: "bash",
+        toolState: "completed",
+      }),
+    )
+  })
+
   test("merges failed local rows ahead of later persisted prompts", () => {
     const persisted = {
       kind: "user",

+ 2 - 2
packages/opencode/test/cli/run/stream.transport.test.ts

@@ -1183,7 +1183,7 @@ describe("run stream transport", () => {
     }
   })
 
-  test("drops completed historical subagent tabs during bootstrap", async () => {
+  test("keeps completed historical subagent tabs during bootstrap", async () => {
     const src = eventFeed()
     const ui = footer()
     const transport = await createSessionTransport({
@@ -1231,7 +1231,7 @@ describe("run stream transport", () => {
         return item?.type === "stream.subagent" ? item.state : undefined
       })
 
-      expect(state.tabs).toEqual([])
+      expect(state.tabs).toEqual([expect.objectContaining({ sessionID: "child-1", status: "completed" })])
       expect(state.details).toEqual({})
     } finally {
       src.close()

+ 98 - 7
packages/opencode/test/cli/run/subagent-data.test.ts

@@ -4,7 +4,6 @@ import { entryBody } from "@/cli/cmd/run/entry.body"
 import {
   bootstrapSubagentCalls,
   bootstrapSubagentData,
-  clearFinishedSubagents,
   createSubagentData,
   reduceSubagentData,
   snapshotSubagentData,
@@ -50,7 +49,7 @@ function reduce(data: ReturnType<typeof createSubagentData>, event: unknown) {
   })
 }
 
-function taskMessage(sessionID: string, status: "running" | "completed" = "completed"): SessionMessage {
+function taskMessage(sessionID: string, status: "running" | "completed" | "interrupted" = "completed"): SessionMessage {
   if (status === "running") {
     return {
       parts: [
@@ -79,6 +78,35 @@ function taskMessage(sessionID: string, status: "running" | "completed" = "compl
     }
   }
 
+  if (status === "interrupted") {
+    return {
+      parts: [
+        {
+          id: `part-${sessionID}`,
+          sessionID: "parent-1",
+          messageID: `msg-${sessionID}`,
+          type: "tool",
+          callID: `call-${sessionID}`,
+          tool: "task",
+          state: {
+            status: "error",
+            input: {
+              description: "Scan reducer paths",
+              subagent_type: "explore",
+            },
+            error: "Tool execution aborted",
+            metadata: {
+              sessionId: sessionID,
+              toolcalls: 4,
+              interrupted: true,
+            },
+            time: { start: 1, end: 2 },
+          },
+        },
+      ],
+    }
+  }
+
   return {
     parts: [
       {
@@ -234,6 +262,25 @@ describe("run subagent data", () => {
     expect(snapshot.questions.map((item) => item.id)).toEqual(["question-1"])
   })
 
+  test("marks interrupted task tabs as cancelled during bootstrap", () => {
+    const data = createSubagentData()
+
+    bootstrapSubagentData({
+      data,
+      messages: [taskMessage("child-1", "interrupted")],
+      children: [{ id: "child-1" }],
+      permissions: [],
+      questions: [],
+    })
+
+    expect(snapshotSubagentData(data).tabs).toEqual([
+      expect.objectContaining({
+        sessionID: "child-1",
+        status: "cancelled",
+      }),
+    ])
+  })
+
   test("captures child activity and blocker metadata in the footer detail state", () => {
     const data = createSubagentData()
 
@@ -437,20 +484,64 @@ describe("run subagent data", () => {
     ])
   })
 
-  test("clears finished tabs on the next parent prompt", () => {
+  test("marks a running tab cancelled when the child session aborts", () => {
     const data = createSubagentData()
 
     bootstrapSubagentData({
       data,
-      messages: [taskMessage("child-1", "completed"), taskMessage("child-2", "running")],
-      children: [{ id: "child-1" }, { id: "child-2" }],
+      messages: [taskMessage("child-1", "running")],
+      children: [{ id: "child-1" }],
       permissions: [],
       questions: [],
     })
 
-    expect(clearFinishedSubagents(data)).toBe(true)
+    reduce(data, {
+      type: "message.updated",
+      properties: {
+        sessionID: "child-1",
+        info: {
+          id: "msg-assistant-1",
+          sessionID: "child-1",
+          role: "assistant",
+          time: {
+            created: 1,
+            completed: 2,
+          },
+          error: {
+            name: "MessageAbortedError",
+            data: {
+              message: "Aborted",
+            },
+          },
+          parentID: "msg-user-1",
+          providerID: "openai",
+          modelID: "gpt-5",
+          mode: "default",
+          agent: "explore",
+          path: {
+            cwd: "/tmp",
+            root: "/tmp",
+          },
+          cost: 0,
+          tokens: {
+            input: 1,
+            output: 1,
+            reasoning: 0,
+            cache: {
+              read: 0,
+              write: 0,
+            },
+          },
+          finish: "error",
+        },
+      },
+    })
+
     expect(snapshotSubagentData(data).tabs).toEqual([
-      expect.objectContaining({ sessionID: "child-2", status: "running" }),
+      expect.objectContaining({
+        sessionID: "child-1",
+        status: "cancelled",
+      }),
     ])
   })
 })

+ 25 - 0
packages/opencode/test/cli/run/theme.test.ts

@@ -74,8 +74,33 @@ test("returns syntax styles and indexed splash colors", async () => {
     expectIndexed(theme.splash.right)
     expectIndexed(theme.splash.leftShadow)
     expectIndexed(theme.splash.rightShadow)
+    expectIndexed(theme.block.highlight)
+    expectIndexed(theme.block.warning)
     expectRgba(theme.footer.highlight)
+    expectRgba(theme.footer.statusAccent)
     expectRgba(theme.footer.surface)
+    expect(expectRgba(theme.footer.statusAccent).toInts()).not.toEqual(expectRgba(theme.footer.status).toInts())
+  } finally {
+    theme.block.syntax?.destroy()
+    theme.block.subtleSyntax?.destroy()
+  }
+})
+
+test("keeps footer surfaces exact while scrollback stays palette matched", async () => {
+  const colors = terminalColors({
+    defaultBackground: "#0f172a",
+    defaultForeground: "#e2e8f0",
+  })
+  const theme = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
+  const exact = resolveTheme(generateSystem(colors, "dark"), "dark")
+
+  try {
+    expect(expectRgba(theme.footer.selected).toInts()).toEqual(expectRgba(exact.backgroundElement).toInts())
+    expect(expectRgba(theme.footer.border).toInts()).toEqual(expectRgba(exact.border).toInts())
+    expect(expectRgba(theme.footer.pane).toInts()).toEqual(expectRgba(exact.backgroundMenu).toInts())
+    expect(expectRgba(theme.footer.selected).intent).toBe("rgb")
+    expectIndexed(theme.block.highlight)
+    expectIndexed(theme.block.warning)
   } finally {
     theme.block.syntax?.destroy()
     theme.block.subtleSyntax?.destroy()

+ 12 - 11
packages/tui/src/component/prompt/index.tsx

@@ -26,7 +26,7 @@ import { useProject } from "../../context/project"
 import { useSync } from "../../context/sync"
 import { useEvent } from "../../context/event"
 import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor"
-import { openEditor } from "../../editor"
+import { normalizePromptContent, openEditor } from "../../editor"
 import { destroyRenderer } from "../../util/renderer"
 import { promptOffsetWidth } from "../../prompt/display"
 import { createStore, produce, unwrap } from "solid-js/store"
@@ -442,8 +442,9 @@ export function Prompt(props: PromptProps) {
               paths.cwd,
           })
           if (!content) return
+          const normalized = normalizePromptContent(content)
 
-          input.setText(content)
+          input.setText(normalized)
 
           // Update positions for nonTextParts based on their location in new content
           // Filter out parts whose virtual text was deleted
@@ -460,7 +461,7 @@ export function Prompt(props: PromptProps) {
 
               if (!virtualText) return part
 
-              const newStart = content.indexOf(virtualText)
+               const newStart = normalized.indexOf(virtualText)
               // if the virtual text is deleted, remove the part
               if (newStart === -1) return null
 
@@ -495,14 +496,14 @@ export function Prompt(props: PromptProps) {
             })
             .filter((part) => part !== null)
 
-          setStore("prompt", {
-            input: content,
-            // keep only the non-text parts because the text parts were
-            // already expanded inline
-            parts: updatedNonTextParts,
-          })
-          restoreExtmarksFromParts(updatedNonTextParts)
-          input.cursorOffset = Bun.stringWidth(content)
+           setStore("prompt", {
+             input: normalized,
+             // keep only the non-text parts because the text parts were
+             // already expanded inline
+             parts: updatedNonTextParts,
+           })
+           restoreExtmarksFromParts(updatedNonTextParts)
+           input.cursorOffset = Bun.stringWidth(normalized)
         },
       },
       {

+ 19 - 2
packages/tui/src/editor.ts

@@ -4,9 +4,26 @@ import { readFile, rm, writeFile } from "node:fs/promises"
 import os from "node:os"
 import path from "node:path"
 import { spawn } from "node:child_process"
+import type { Stream } from "node:stream"
 import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
 
-export async function openEditor(input: { value: string; renderer: CliRenderer; cwd?: string }) {
+type EditorStdio = "inherit" | "pipe" | "ignore" | number | Stream
+
+export function normalizePromptContent(content: string) {
+  if (content.endsWith("\r\n")) {
+    const body = content.slice(0, -2)
+    return !body.includes("\n") && !body.includes("\r") ? body : content
+  }
+
+  if (content.endsWith("\n")) {
+    const body = content.slice(0, -1)
+    return !body.includes("\n") && !body.includes("\r") ? body : content
+  }
+
+  return content
+}
+
+export async function openEditor(input: { value: string; renderer: CliRenderer; cwd?: string; stdin?: EditorStdio }) {
   const editor = process.env.VISUAL || process.env.EDITOR
   if (!editor) return
   const file = path.join(os.tmpdir(), `${Date.now()}.md`)
@@ -18,7 +35,7 @@ export async function openEditor(input: { value: string; renderer: CliRenderer;
       const parts = editor.split(" ")
       const child = spawn(parts[0]!, [...parts.slice(1), file], {
         cwd: input.cwd && existsSync(input.cwd) ? input.cwd : process.cwd(),
-        stdio: "inherit",
+        stdio: [input.stdin ?? "inherit", "inherit", "inherit"],
         shell: process.platform === "win32",
       })
       child.on("error", reject)

+ 10 - 1
packages/tui/test/editor.test.ts

@@ -1,5 +1,5 @@
 import { afterEach, expect, test } from "bun:test"
-import { openEditor } from "../src/editor"
+import { normalizePromptContent, openEditor } from "../src/editor"
 
 const editor = process.env.EDITOR
 const visual = process.env.VISUAL
@@ -21,3 +21,12 @@ test("rejects when the external editor cannot start", async () => {
 
   await expect(openEditor({ value: "original", renderer: renderer as never })).rejects.toThrow()
 })
+
+test("normalizes a single trailing editor newline for one-line prompts", () => {
+  expect(normalizePromptContent("hello\n")).toBe("hello")
+  expect(normalizePromptContent("hello\r\n")).toBe("hello")
+})
+
+test("preserves multiline prompts that end with a newline", () => {
+  expect(normalizePromptContent("hello\nworld\n")).toBe("hello\nworld\n")
+})

Неке датотеке нису приказане због велике количине промена