@madsoftwaredev/opencode-chat-title 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MadSoftwareDev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,90 @@
1
- # Temporary Holding Version
1
+ # @madsoftwaredev/opencode-chat-title
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Pins the current session title to a one-row header above the chat transcript,
4
+ directly under the session tab strip, while the session sidebar is hidden.
5
+
6
+ ## Install
7
+
8
+ Add this CLI-only plugin to your global `~/.config/opencode/cli.json`:
9
+
10
+ ```json
11
+ {
12
+ "plugins": ["@madsoftwaredev/opencode-chat-title"]
13
+ }
14
+ ```
15
+
16
+ Merge the entry into your existing `plugins` list, then restart the OpenCode TUI.
17
+ A server restart or server-side plugin reload does not load a TUI entrypoint.
18
+ Do not also load the original local `plugins/chat-title` copy: it has the same
19
+ `chat-title` plugin ID and storage keys.
20
+
21
+ ## Use
22
+
23
+ - Shown only while the session sidebar is **not** on screen. The sidebar already
24
+ shows the session title at its own top, so the plugin avoids printing it twice.
25
+ - The header returns when the sidebar is absent: hidden by terminal width or
26
+ `session.sidebar` in `cli.json`, replaced by the terminal or panel pane, or
27
+ viewing a subagent session.
28
+ - Takes one row from the transcript instead of covering it. Only the chat column
29
+ changes; the sidebar and right-hand pane are unchanged.
30
+ - Follows the live session title and shortens it to the available terminal cells
31
+ with a trailing `…`. Width fitting handles grapheme clusters and common CJK
32
+ and emoji ranges; terminal-specific width differences are still possible.
33
+ - Use `/chat-title` or **Toggle the chat title header** in `Ctrl+P` to turn it
34
+ off and on. The choice is saved across restarts and synced across TUI instances.
35
+
36
+ ## Compatibility and limitations
37
+
38
+ The supported development baseline is **OpenCode 2.1.0**, **OpenTUI 0.6.6**,
39
+ and **Solid 1.9.12**. This is not a guarantee that every later OpenCode layout
40
+ will work. The package exports `./tui` only and runs in the OpenCode terminal,
41
+ not in the web or desktop UI. Its Node `>=22` engine metadata does not make the
42
+ TypeScript/TSX entrypoint a standalone Node program.
43
+
44
+ OpenCode V2 publishes no slot above the transcript. This plugin uses the exposed
45
+ OpenTUI render tree and renderable style getters, not a browser DOM, to insert
46
+ the header into the session column that owns the `session.composer.top` slot.
47
+
48
+ Sidebar visibility is tracked through the documented `sidebar.content` slot,
49
+ which mounts while the sidebar renders. Replacing that slot can interfere with
50
+ this visibility signal.
51
+
52
+ The adapter matches the layout inspected in OpenCode 2.0.27: exactly three rows
53
+ for the transcript (a box wrapping the scrollbox), the one-row "jump to latest"
54
+ bar, and the composer branch containing the plugin anchor. Unrecognized layouts
55
+ are left alone and reported with a warning toast. Errors during initial layout
56
+ discovery also leave the transcript intact rather than crashing the composer
57
+ slot. OpenCode updates or other plugins that change that tree may require an
58
+ adapter update. No installed
59
+ binaries or OpenCode source files are patched.
60
+
61
+ Version 0.1.1 replaces the removed `getLayoutNode()` API with renderable style
62
+ getters. The header requests a frame after insertion and uses `onSizeChange` to
63
+ refit its text after native layout and terminal resizes. It never accesses or
64
+ modifies a raw Yoga node. Host structure remains an implementation dependency,
65
+ not a public layout guarantee.
66
+
67
+ OpenCode supplies the UI runtime and rewrites host imports in `tui.tsx`.
68
+ `header.ts` and `title.ts` deliberately avoid runtime imports of UI packages so
69
+ the adapter receives the host's own constructors.
70
+
71
+ ## Development checks
72
+
73
+ Install workspace dependencies once from the repository root, then run:
74
+
75
+ ```sh
76
+ bun run --cwd packages/chat-title test
77
+ bun run --cwd packages/chat-title typecheck
78
+ bun build ./packages/chat-title/tui.tsx --target=bun --external '@opencode/*' --external '@opentui/*' --external 'solid-js'
79
+ ```
80
+
81
+ Unit tests cover title fitting and render-tree matching. Headless OpenTUI 0.6.6
82
+ integration tests cover rendering, title updates, narrow-terminal resizing,
83
+ sidebar visibility, saved-toggle wiring, disposal, and safe layout rejection.
84
+ They use the inspected host shape, not a live installed OpenCode release.
85
+ The build command checks transpilation, not host integration. Development
86
+ dependencies are not needed to load the plugin in OpenCode.
87
+
88
+ ## License
89
+
90
+ MIT
package/header.ts ADDED
@@ -0,0 +1,158 @@
1
+ import { fitTitle } from "./title"
2
+
3
+ // OpenCode publishes no slot above the session transcript, so the header is
4
+ // inserted into the host's render tree. OpenCode rewrites "@opentui/*"
5
+ // specifiers in the TSX entrypoint only, so this helper stays free of those
6
+ // imports and receives the host's own constructors from the entrypoint instead.
7
+
8
+ /** Cells kept clear on each side of the title text. */
9
+ export const PADDING_X = 2
10
+
11
+ export type YogaValue = { readonly unit: number; readonly value: number }
12
+
13
+ export type YogaLayout = {
14
+ getPadding(edge: number): YogaValue
15
+ getFlexGrow(): number
16
+ getFlexShrink(): number
17
+ getFlexDirection(): number
18
+ getHeight(): YogaValue
19
+ }
20
+
21
+ export type Node = YogaLayout & {
22
+ readonly isDestroyed: boolean
23
+ readonly parent: Node | null
24
+ readonly width: number
25
+ getChildren(): Node[]
26
+ onSizeChange?: () => void
27
+ add(child: unknown, index?: number): number
28
+ destroy(): void
29
+ }
30
+
31
+ export type LabelNode = Node & { content: string }
32
+
33
+ export type Core = {
34
+ BoxRenderable: new (ctx: unknown, options: Record<string, unknown>) => Node
35
+ ScrollBoxRenderable: new (...args: never[]) => Node
36
+ TextRenderable: new (ctx: unknown, options: Record<string, unknown>) => LabelNode
37
+ Yoga: {
38
+ Edge: { Bottom: number }
39
+ Unit: { Point: number }
40
+ FlexDirection: { Row: number }
41
+ }
42
+ }
43
+
44
+ export type Attachment = {
45
+ /** Re-read the title and refit it to the current column width. */
46
+ refresh: () => void
47
+ dispose: () => void
48
+ }
49
+
50
+ export type AttachOptions = {
51
+ core: Core
52
+ anchor: Node
53
+ renderer: { requestRender?: () => void }
54
+ title: () => string
55
+ color: () => unknown
56
+ }
57
+
58
+ // OpenCode has no public slot or API for a transcript header. Match only the
59
+ // inspected V2 host shape: a column holding the transcript, the one-row
60
+ // "jump to latest" bar, and the composer block that owns our anchor. Never
61
+ // insert into an unrecognized ancestor when an OpenCode update changes it.
62
+ function isColumn(node: Node, branch: Node, core: Core): boolean {
63
+ const children = node.getChildren()
64
+ if (children.length !== 3 || children[2] !== branch) return false
65
+ if (!(children[0] instanceof core.BoxRenderable)) return false
66
+ if (!(children[0].getChildren()[0] instanceof core.ScrollBoxRenderable)) return false
67
+
68
+ const jump = children[1]
69
+ const jumpHeight = jump.getHeight()
70
+ if (jumpHeight.unit !== core.Yoga.Unit.Point || jumpHeight.value !== 1) return false
71
+ if (jump.getFlexShrink() !== 0) return false
72
+
73
+ const padding = node.getPadding(core.Yoga.Edge.Bottom)
74
+ if (padding.unit !== core.Yoga.Unit.Point || padding.value !== 1) return false
75
+ if (node.getFlexGrow() !== 1) return false
76
+
77
+ // The column sits beside the sidebar, so its own parent is a row.
78
+ const parent = node.parent
79
+ if (!parent) return false
80
+ return parent.getFlexDirection() === core.Yoga.FlexDirection.Row
81
+ }
82
+
83
+ /**
84
+ * Walk up from the composer anchor to the session column. Returns undefined
85
+ * unless every row matches, so an unsupported layout shows no header at all
86
+ * rather than painting one in the wrong place.
87
+ */
88
+ export function findSessionColumn(anchor: Node, core: Core): Node | undefined {
89
+ let branch: Node = anchor
90
+ for (let node = anchor.parent; node; branch = node, node = node.parent) {
91
+ if (isColumn(node, branch, core)) return node
92
+ }
93
+ return undefined
94
+ }
95
+
96
+ /**
97
+ * Insert a one-row title above the session transcript. The host column is a
98
+ * flexbox, so the header takes a row from the transcript instead of covering it.
99
+ */
100
+ export function attachHeader(options: AttachOptions): Attachment | undefined {
101
+ let column: Node | undefined
102
+ try {
103
+ column = findSessionColumn(options.anchor, options.core)
104
+ } catch (error) {
105
+ console.warn("[chat-title] Unsupported render tree; the chat title is hidden", error)
106
+ return undefined
107
+ }
108
+ if (!column) return undefined
109
+
110
+ const header = new options.core.BoxRenderable(options.renderer, {
111
+ id: "chat-title.header",
112
+ height: 1,
113
+ flexShrink: 0,
114
+ flexDirection: "row",
115
+ paddingLeft: PADDING_X,
116
+ paddingRight: PADDING_X,
117
+ })
118
+ const label = new options.core.TextRenderable(options.renderer, {
119
+ fg: options.color(),
120
+ wrapMode: "none",
121
+ truncate: true,
122
+ flexGrow: 1,
123
+ minWidth: 0,
124
+ })
125
+ header.add(label)
126
+ column.add(header, 0)
127
+ // Request a frame so native layout measures the row and invokes onSizeChange.
128
+ options.renderer.requestRender?.()
129
+
130
+ let disposed = false
131
+ let rendered = ""
132
+ const refresh = () => {
133
+ if (disposed || header.isDestroyed || column.isDestroyed) return
134
+ const width = header.width - PADDING_X * 2
135
+ // Layout has not run yet on the first pass; the resize handler refits.
136
+ if (width < 1) return
137
+ const next = fitTitle(options.title(), width)
138
+ if (next === rendered) return
139
+ rendered = next
140
+ label.content = next
141
+ }
142
+
143
+ header.onSizeChange = refresh
144
+ refresh()
145
+
146
+ return {
147
+ refresh,
148
+ dispose() {
149
+ if (disposed) return
150
+ disposed = true
151
+ if (!header.isDestroyed) header.onSizeChange = undefined
152
+ // The host detaches children without destroying them, so drop the label
153
+ // first and then the row itself.
154
+ if (!label.isDestroyed) label.destroy()
155
+ if (!header.isDestroyed) header.destroy()
156
+ },
157
+ }
158
+ }
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@madsoftwaredev/opencode-chat-title",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.2",
4
+ "description": "OpenCode V2 TUI plugin that shows the session title above the transcript while the sidebar is hidden.",
5
+ "keywords": [
6
+ "opencode",
7
+ "opencode-plugin",
8
+ "chat-title",
9
+ "session",
10
+ "tui"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "MadSoftwareDev",
14
+ "type": "module",
15
+ "exports": {
16
+ "./tui": "./tui.tsx"
17
+ },
18
+ "files": [
19
+ "tui.tsx",
20
+ "header.ts",
21
+ "title.ts",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/madsoftwaredev/opencode-plugins.git",
28
+ "directory": "packages/chat-title"
29
+ },
30
+ "homepage": "https://github.com/madsoftwaredev/opencode-plugins/tree/main/packages/chat-title#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/madsoftwaredev/opencode-plugins/issues"
33
+ },
34
+ "engines": {
35
+ "node": ">=22"
36
+ },
37
+ "scripts": {
38
+ "test": "bun test --preload @opentui/solid/preload",
39
+ "typecheck": "tsc --noEmit"
40
+ },
41
+ "dependencies": {
42
+ "@opencode/plugin": "^2.1.0"
43
+ },
44
+ "peerDependencies": {
45
+ "@opentui/core": ">=0.6.6",
46
+ "@opentui/solid": ">=0.6.6",
47
+ "solid-js": ">=1.9.12"
48
+ },
49
+ "devDependencies": {
50
+ "@opentui/core": "0.8.1",
51
+ "@opentui/solid": "0.8.1",
52
+ "@types/bun": "1.4.0",
53
+ "typescript": "5.8.2"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ }
58
+ }
package/title.ts ADDED
@@ -0,0 +1,56 @@
1
+ const ELLIPSIS = "…"
2
+
3
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
4
+
5
+ // Ranges that terminals render in two cells. Chat titles are usually ASCII, but
6
+ // a CJK or emoji title must not push the header text past its column.
7
+ const WIDE: [number, number][] = [
8
+ [0x1100, 0x115f],
9
+ [0x2e80, 0x303e],
10
+ [0x3041, 0x33ff],
11
+ [0x3400, 0x4dbf],
12
+ [0x4e00, 0x9fff],
13
+ [0xa000, 0xa4cf],
14
+ [0xac00, 0xd7a3],
15
+ [0xf900, 0xfaff],
16
+ [0xfe30, 0xfe6f],
17
+ [0xff00, 0xff60],
18
+ [0xffe0, 0xffe6],
19
+ [0x1f300, 0x1f64f],
20
+ [0x1f900, 0x1f9ff],
21
+ [0x20000, 0x3fffd],
22
+ ]
23
+
24
+ function cellWidth(grapheme: string): number {
25
+ const code = grapheme.codePointAt(0) ?? 0
26
+ return WIDE.some(([low, high]) => code >= low && code <= high) ? 2 : 1
27
+ }
28
+
29
+ /** Terminal cells a string occupies, counting grapheme clusters. */
30
+ export function displayWidth(text: string): number {
31
+ let total = 0
32
+ for (const { segment } of segmenter.segment(text)) total += cellWidth(segment)
33
+ return total
34
+ }
35
+
36
+ /**
37
+ * Collapse whitespace the way a session title does when it is generated, then
38
+ * shorten to `width` cells with a trailing ellipsis. Widths below one cell
39
+ * yield an empty string so a zero-width header never paints text.
40
+ */
41
+ export function fitTitle(title: string, width: number): string {
42
+ const text = title.replace(/\s+/g, " ").trim()
43
+ if (width < 1) return ""
44
+ if (displayWidth(text) <= width) return text
45
+
46
+ let used = 0
47
+ let head = ""
48
+ for (const { segment } of segmenter.segment(text)) {
49
+ const next = cellWidth(segment)
50
+ // One cell is held back for the ellipsis itself.
51
+ if (used + next > width - 1) break
52
+ head += segment
53
+ used += next
54
+ }
55
+ return head.trimEnd() + ELLIPSIS
56
+ }
package/tui.tsx ADDED
@@ -0,0 +1,127 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import { BoxRenderable, ScrollBoxRenderable, TextRenderable, Yoga } from "@opentui/core"
4
+ import { Plugin } from "@opencode/plugin/tui"
5
+ import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
6
+ import { attachHeader, type Attachment, type Core } from "./header"
7
+
8
+ // OpenCode rewrites "@opentui/*" specifiers in this entrypoint and hands over
9
+ // the host's own constructors, so ./header.ts can stay free of those imports.
10
+ // TypeScript cannot check across that boundary, hence the single cast.
11
+ const core = { BoxRenderable, ScrollBoxRenderable, TextRenderable, Yoga } as unknown as Core
12
+
13
+ export default Plugin.define({
14
+ id: "chat-title",
15
+ setup(context) {
16
+ const [settings, update] = context.storage.store<{ enabled: boolean }>("settings", {
17
+ initial: { enabled: true },
18
+ })
19
+ // `override` applies a toggle immediately and is dropped once the store
20
+ // catches up, so a failed write falls back to the persisted value.
21
+ const [override, setOverride] = createSignal<boolean>()
22
+ const enabled = createMemo(() => override() ?? settings.enabled !== false)
23
+
24
+ const toggle = async () => {
25
+ const next = !enabled()
26
+ setOverride(next)
27
+ try {
28
+ await update((draft) => {
29
+ draft.enabled = next
30
+ })
31
+ } catch (error) {
32
+ setOverride(undefined)
33
+ console.warn("[chat-title] Could not save setting", error)
34
+ context.ui.toast.show({ message: "Could not save the chat title setting.", variant: "error" })
35
+ }
36
+ }
37
+
38
+ // The session sidebar already shows the session title at its own top, so the
39
+ // conversation header is only wanted while that sidebar is off screen. This
40
+ // slot mounts only when the sidebar is rendered, which covers every reason
41
+ // it can be absent: hidden by width or `session.sidebar`, replaced by the
42
+ // terminal or panel pane, or a subagent session.
43
+ const [sidebarOpen, setSidebarOpen] = createSignal(false)
44
+ const trackSidebar = context.ui.slot({
45
+ append: "sidebar.content",
46
+ render: () => {
47
+ onMount(() => setSidebarOpen(true))
48
+ onCleanup(() => setSidebarOpen(false))
49
+ return null
50
+ },
51
+ })
52
+
53
+ // The host keys the session view by session ID, so this slot mounts once per
54
+ // session. The zero-size anchor gives the render-tree walk a starting point
55
+ // without costing the composer a row.
56
+ const unregister = context.ui.slot({
57
+ append: "session.composer.top",
58
+ render: (input) => {
59
+ let anchor: BoxRenderable | undefined
60
+ const title = createMemo(() => context.data.session.get(input.sessionID)?.title ?? "")
61
+ const [attachment, setAttachment] = createSignal<Attachment>()
62
+
63
+ createEffect(() => {
64
+ const node = anchor
65
+ if (!node || !enabled() || sidebarOpen()) return
66
+ const header = attachHeader({
67
+ core,
68
+ anchor: node,
69
+ renderer: context.renderer,
70
+ title,
71
+ color: () => context.theme.text.base,
72
+ })
73
+ if (!header) {
74
+ context.ui.toast.show({
75
+ title: "Chat title",
76
+ message: "Unsupported OpenCode layout; the chat title is hidden.",
77
+ variant: "warning",
78
+ })
79
+ return
80
+ }
81
+ setAttachment(() => header)
82
+ onCleanup(() => {
83
+ header.dispose()
84
+ setAttachment(undefined)
85
+ })
86
+ })
87
+
88
+ createEffect(() => {
89
+ title()
90
+ attachment()?.refresh()
91
+ })
92
+
93
+ return (
94
+ <box
95
+ ref={(node) => {
96
+ anchor = node
97
+ }}
98
+ id="chat-title.anchor"
99
+ visible={false}
100
+ width={0}
101
+ height={0}
102
+ />
103
+ )
104
+ },
105
+ })
106
+
107
+ context.keymap.layer(() => ({
108
+ mode: "global",
109
+ commands: [
110
+ {
111
+ id: "chat-title.toggle",
112
+ title: "Toggle the chat title header",
113
+ group: "Appearance",
114
+ palette: true,
115
+ slash: { name: "chat-title" },
116
+ run: toggle,
117
+ },
118
+ ],
119
+ bindings: ["chat-title.toggle"],
120
+ }))
121
+
122
+ return () => {
123
+ unregister()
124
+ trackSidebar()
125
+ }
126
+ },
127
+ })