@madsoftwaredev/opencode-chat-title 0.0.0-stage → 0.1.0
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 +21 -0
- package/README.md +87 -2
- package/header.ts +157 -0
- package/package.json +54 -4
- package/title.ts +56 -0
- package/tui.tsx +127 -0
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,88 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @madsoftwaredev/opencode-chat-title
|
|
2
2
|
|
|
3
|
-
|
|
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.0.27**, **OpenTUI 0.5.17**,
|
|
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 renderer and Yoga layout tree, not a browser DOM, to insert the header
|
|
46
|
+
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. OpenCode updates or other
|
|
56
|
+
plugins that change that tree may require an adapter update. No installed
|
|
57
|
+
binaries or OpenCode source files are patched.
|
|
58
|
+
|
|
59
|
+
The original local implementation records two host behaviors: `requestRender()`
|
|
60
|
+
is needed for a newly inserted row to be measured, and renderables emit `resize`
|
|
61
|
+
for Yoga layout changes (`resized` is a different root-only event). It also
|
|
62
|
+
records that calling Yoga's `markDirty()` crashes the native renderer; this
|
|
63
|
+
plugin does not call it. These are implementation dependencies, not public
|
|
64
|
+
layout guarantees.
|
|
65
|
+
|
|
66
|
+
OpenCode supplies the UI runtime and rewrites host imports in `tui.tsx`.
|
|
67
|
+
`header.ts` and `title.ts` deliberately avoid runtime imports of UI packages so
|
|
68
|
+
the adapter receives the host's own constructors.
|
|
69
|
+
|
|
70
|
+
## Development checks
|
|
71
|
+
|
|
72
|
+
Install workspace dependencies once from the repository root, then run:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
bun test ./packages/chat-title
|
|
76
|
+
bun run --cwd packages/chat-title typecheck
|
|
77
|
+
bun build ./packages/chat-title/tui.tsx --target=bun --external '@opencode/*' --external '@opentui/*' --external 'solid-js'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Tests cover title fitting, render-tree rejection, header insertion, resizing,
|
|
81
|
+
and disposal using fake renderables. They do not prove the actual OpenCode
|
|
82
|
+
layout, sidebar visibility lifecycle, or persisted settings in a running TUI.
|
|
83
|
+
The build command checks transpilation, not host integration. Development
|
|
84
|
+
dependencies are not needed to load the plugin in OpenCode.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
package/header.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
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 = {
|
|
22
|
+
readonly isDestroyed: boolean
|
|
23
|
+
readonly parent: Node | null
|
|
24
|
+
readonly width: number
|
|
25
|
+
getChildren(): Node[]
|
|
26
|
+
getLayoutNode(): YogaLayout
|
|
27
|
+
add(child: unknown, index?: number): number
|
|
28
|
+
destroy(): void
|
|
29
|
+
on(event: string, handler: () => void): void
|
|
30
|
+
off(event: string, handler: () => void): void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type LabelNode = Node & { content: string }
|
|
34
|
+
|
|
35
|
+
export type Core = {
|
|
36
|
+
BoxRenderable: new (ctx: unknown, options: Record<string, unknown>) => Node
|
|
37
|
+
ScrollBoxRenderable: new (...args: never[]) => Node
|
|
38
|
+
TextRenderable: new (ctx: unknown, options: Record<string, unknown>) => LabelNode
|
|
39
|
+
Yoga: {
|
|
40
|
+
Edge: { Bottom: number }
|
|
41
|
+
Unit: { Point: number }
|
|
42
|
+
FlexDirection: { Row: number }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type Attachment = {
|
|
47
|
+
/** Re-read the title and refit it to the current column width. */
|
|
48
|
+
refresh: () => void
|
|
49
|
+
dispose: () => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type AttachOptions = {
|
|
53
|
+
core: Core
|
|
54
|
+
anchor: Node
|
|
55
|
+
renderer: { requestRender?: () => void }
|
|
56
|
+
title: () => string
|
|
57
|
+
color: () => unknown
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// OpenCode has no public slot or API for a transcript header. Match only the
|
|
61
|
+
// inspected V2 host shape: a column holding the transcript, the one-row
|
|
62
|
+
// "jump to latest" bar, and the composer block that owns our anchor. Never
|
|
63
|
+
// insert into an unrecognized ancestor when an OpenCode update changes it.
|
|
64
|
+
function isColumn(node: Node, branch: Node, core: Core): boolean {
|
|
65
|
+
const children = node.getChildren()
|
|
66
|
+
if (children.length !== 3 || children[2] !== branch) return false
|
|
67
|
+
if (!(children[0] instanceof core.BoxRenderable)) return false
|
|
68
|
+
if (!(children[0].getChildren()[0] instanceof core.ScrollBoxRenderable)) return false
|
|
69
|
+
|
|
70
|
+
const jump = children[1].getLayoutNode()
|
|
71
|
+
const jumpHeight = jump.getHeight()
|
|
72
|
+
if (jumpHeight.unit !== core.Yoga.Unit.Point || jumpHeight.value !== 1) return false
|
|
73
|
+
if (jump.getFlexShrink() !== 0) return false
|
|
74
|
+
|
|
75
|
+
const layout = node.getLayoutNode()
|
|
76
|
+
const padding = layout.getPadding(core.Yoga.Edge.Bottom)
|
|
77
|
+
if (padding.unit !== core.Yoga.Unit.Point || padding.value !== 1) return false
|
|
78
|
+
if (layout.getFlexGrow() !== 1) return false
|
|
79
|
+
|
|
80
|
+
// The column sits beside the sidebar, so its own parent is a row.
|
|
81
|
+
const parent = node.parent
|
|
82
|
+
if (!parent) return false
|
|
83
|
+
return parent.getLayoutNode().getFlexDirection() === core.Yoga.FlexDirection.Row
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Walk up from the composer anchor to the session column. Returns undefined
|
|
88
|
+
* unless every row matches, so an unsupported layout shows no header at all
|
|
89
|
+
* rather than painting one in the wrong place.
|
|
90
|
+
*/
|
|
91
|
+
export function findSessionColumn(anchor: Node, core: Core): Node | undefined {
|
|
92
|
+
let branch: Node = anchor
|
|
93
|
+
for (let node = anchor.parent; node; branch = node, node = node.parent) {
|
|
94
|
+
if (isColumn(node, branch, core)) return node
|
|
95
|
+
}
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Insert a one-row title above the session transcript. The host column is a
|
|
101
|
+
* flexbox, so the header takes a row from the transcript instead of covering it.
|
|
102
|
+
*/
|
|
103
|
+
export function attachHeader(options: AttachOptions): Attachment | undefined {
|
|
104
|
+
const column = findSessionColumn(options.anchor, options.core)
|
|
105
|
+
if (!column) return undefined
|
|
106
|
+
|
|
107
|
+
const header = new options.core.BoxRenderable(options.renderer, {
|
|
108
|
+
id: "chat-title.header",
|
|
109
|
+
height: 1,
|
|
110
|
+
flexShrink: 0,
|
|
111
|
+
flexDirection: "row",
|
|
112
|
+
paddingLeft: PADDING_X,
|
|
113
|
+
paddingRight: PADDING_X,
|
|
114
|
+
})
|
|
115
|
+
const label = new options.core.TextRenderable(options.renderer, {
|
|
116
|
+
fg: options.color(),
|
|
117
|
+
wrapMode: "none",
|
|
118
|
+
truncate: true,
|
|
119
|
+
flexGrow: 1,
|
|
120
|
+
minWidth: 0,
|
|
121
|
+
})
|
|
122
|
+
header.add(label)
|
|
123
|
+
column.add(header, 0)
|
|
124
|
+
// add() marks the Yoga tree dirty and requests a frame, so the header is
|
|
125
|
+
// measured on the next pass and "resize" fires with a real width.
|
|
126
|
+
options.renderer.requestRender?.()
|
|
127
|
+
|
|
128
|
+
let disposed = false
|
|
129
|
+
let rendered = ""
|
|
130
|
+
const refresh = () => {
|
|
131
|
+
if (disposed || header.isDestroyed || column.isDestroyed) return
|
|
132
|
+
const width = header.width - PADDING_X * 2
|
|
133
|
+
// Layout has not run yet on the first pass; the resize handler refits.
|
|
134
|
+
if (width < 1) return
|
|
135
|
+
const next = fitTitle(options.title(), width)
|
|
136
|
+
if (next === rendered) return
|
|
137
|
+
rendered = next
|
|
138
|
+
label.content = next
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// "resize" is the event Renderable emits when Yoga gives it a new size.
|
|
142
|
+
header.on("resize", refresh)
|
|
143
|
+
refresh()
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
refresh,
|
|
147
|
+
dispose() {
|
|
148
|
+
if (disposed) return
|
|
149
|
+
disposed = true
|
|
150
|
+
header.off("resize", refresh)
|
|
151
|
+
// The host detaches children without destroying them, so drop the label
|
|
152
|
+
// first and then the row itself.
|
|
153
|
+
if (!label.isDestroyed) label.destroy()
|
|
154
|
+
if (!header.isDestroyed) header.destroy()
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@madsoftwaredev/opencode-chat-title",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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",
|
|
39
|
+
"typecheck": "tsc --noEmit"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@opencode/plugin": "^2.0.27"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@opentui/core": ">=0.5.17",
|
|
46
|
+
"@opentui/solid": ">=0.5.17",
|
|
47
|
+
"solid-js": ">=1.9.12"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/bun": "1.3.9",
|
|
51
|
+
"typescript": "5.8.2"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
}
|
|
56
|
+
}
|
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
|
+
})
|