opencode-sesh 0.4.0 → 0.5.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/AGENTS.md +3 -1
- package/CHANGELOG.md +11 -1
- package/README.md +2 -0
- package/package.json +1 -1
- package/tui/sesh-panel.tsx +263 -0
package/AGENTS.md
CHANGED
|
@@ -179,7 +179,9 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
179
179
|
`slash: { name: "sesh" }` on the `api.command.register` entry — that is the
|
|
180
180
|
only way to open the dialog. A markdown command under `commands/` cannot drive
|
|
181
181
|
the picker (it just prompts the model), so never ship one; the installer
|
|
182
|
-
removes any leftover `commands/sesh.md`.
|
|
182
|
+
removes any leftover `commands/sesh.md`. The command API never passes slash
|
|
183
|
+
arguments, so subcommands are separate registrations (`/sesh-costs`,
|
|
184
|
+
`/sesh-needs`) — all read-only. Destructive actions stay CLI-only.
|
|
183
185
|
- Do not bind `<leader>l` (native `session_list`) or shadow the native
|
|
184
186
|
`/sessions` command.
|
|
185
187
|
- **The sidebar never binds arrow keys globally.** Keyboard navigation is
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-09-24
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `/sesh-costs` opens the per-project cost digest inside the TUI (24h beside
|
|
15
|
+
lifetime, same message-level sums as `sesh costs`).
|
|
16
|
+
- `/sesh-needs` opens the sessions waiting on you as an openable list (Enter
|
|
17
|
+
opens, Esc closes). Both new slashes are read-only; prune and retitle stay
|
|
18
|
+
CLI-only.
|
|
10
19
|
## [0.4.0] - 2026-09-24
|
|
11
20
|
|
|
12
21
|
### Added
|
|
@@ -258,7 +267,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
258
267
|
- Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
|
|
259
268
|
CI.
|
|
260
269
|
|
|
261
|
-
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.
|
|
270
|
+
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.5.0...HEAD
|
|
271
|
+
[0.5.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.4.0...v0.5.0
|
|
262
272
|
[0.4.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.3.0...v0.4.0
|
|
263
273
|
[0.3.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.1...v0.3.0
|
|
264
274
|
[0.2.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.0...v0.2.1
|
package/README.md
CHANGED
|
@@ -165,6 +165,8 @@ non-interactive `sesh-delete.sh` refuses unless given `--yes`.
|
|
|
165
165
|
| Key | Action |
|
|
166
166
|
| --- | --- |
|
|
167
167
|
| `/sesh` | Open the full picker |
|
|
168
|
+
| `/sesh-costs` | Per-project cost digest (24h beside lifetime) |
|
|
169
|
+
| `/sesh-needs` | Sessions waiting on you (Enter opens) |
|
|
168
170
|
| `ctrl+o` | Open the full picker (also in the command palette) |
|
|
169
171
|
| Type | Search titles, directories and transcript text |
|
|
170
172
|
| `↑`/`↓`, `PgUp`/`PgDn`, `Home`/`End` | Move the selection |
|
package/package.json
CHANGED
package/tui/sesh-panel.tsx
CHANGED
|
@@ -1906,6 +1906,249 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1906
1906
|
api.ui.dialog.setSize("xlarge")
|
|
1907
1907
|
}
|
|
1908
1908
|
|
|
1909
|
+
// Read-only digest dialog: spend per project, last day beside lifetime.
|
|
1910
|
+
// Same message-level sums as `sesh costs`; the dialog just renders them.
|
|
1911
|
+
const openCosts = async () => {
|
|
1912
|
+
if (api.mode.current() !== BASE_MODE) return
|
|
1913
|
+
if (api.renderer.currentFocusedEditor === null) return
|
|
1914
|
+
type CostRow = { directory: string; window: number; lifetime: number; sessions: number }
|
|
1915
|
+
let rows: CostRow[] = []
|
|
1916
|
+
let failed = false
|
|
1917
|
+
try {
|
|
1918
|
+
const db = await openTranscriptDb()
|
|
1919
|
+
if (!db) failed = true
|
|
1920
|
+
else {
|
|
1921
|
+
try {
|
|
1922
|
+
const cutoff = Date.now() - 86400000
|
|
1923
|
+
const raw = (db.query(`
|
|
1924
|
+
SELECT s.directory AS directory,
|
|
1925
|
+
ROUND(SUM(CASE WHEN m.time_created >= ${cutoff} THEN COALESCE(json_extract(m.data, '$.cost'), 0) ELSE 0 END), 4) AS window_cost,
|
|
1926
|
+
ROUND(SUM(COALESCE(json_extract(m.data, '$.cost'), 0)), 4) AS lifetime_cost,
|
|
1927
|
+
COUNT(DISTINCT s.id) AS sessions
|
|
1928
|
+
FROM message m
|
|
1929
|
+
JOIN session s ON s.id = m.session_id
|
|
1930
|
+
WHERE json_extract(m.data, '$.role') = 'assistant'
|
|
1931
|
+
AND json_extract(m.data, '$.cost') IS NOT NULL
|
|
1932
|
+
GROUP BY s.directory
|
|
1933
|
+
ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
1934
|
+
directory: unknown
|
|
1935
|
+
window_cost: unknown
|
|
1936
|
+
lifetime_cost: unknown
|
|
1937
|
+
sessions: unknown
|
|
1938
|
+
}[]
|
|
1939
|
+
rows = raw
|
|
1940
|
+
.filter(
|
|
1941
|
+
(
|
|
1942
|
+
row,
|
|
1943
|
+
): row is {
|
|
1944
|
+
directory: string
|
|
1945
|
+
window_cost: number
|
|
1946
|
+
lifetime_cost: number
|
|
1947
|
+
sessions: number
|
|
1948
|
+
} =>
|
|
1949
|
+
typeof row?.directory === "string" &&
|
|
1950
|
+
typeof row?.window_cost === "number" &&
|
|
1951
|
+
typeof row?.lifetime_cost === "number" &&
|
|
1952
|
+
typeof row?.sessions === "number" &&
|
|
1953
|
+
row.lifetime_cost > 0,
|
|
1954
|
+
)
|
|
1955
|
+
.map((row) => ({
|
|
1956
|
+
directory: row.directory,
|
|
1957
|
+
window: row.window_cost,
|
|
1958
|
+
lifetime: row.lifetime_cost,
|
|
1959
|
+
sessions: row.sessions,
|
|
1960
|
+
}))
|
|
1961
|
+
} finally {
|
|
1962
|
+
db.close()
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
} catch {
|
|
1966
|
+
failed = true
|
|
1967
|
+
}
|
|
1968
|
+
const money = (n: number) => (n === 0 ? "—" : `$${n.toFixed(2)}`)
|
|
1969
|
+
const home = process.env.HOME ?? ""
|
|
1970
|
+
const disposeKeys = api.keymap.registerLayer({
|
|
1971
|
+
bindings: [
|
|
1972
|
+
{ key: "escape", desc: "Close", preventDefault: true, cmd: () => api.ui.dialog.clear() },
|
|
1973
|
+
],
|
|
1974
|
+
})
|
|
1975
|
+
api.ui.dialog.replace(
|
|
1976
|
+
() => (
|
|
1977
|
+
<box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1}>
|
|
1978
|
+
<text attributes={TextAttributes.BOLD}>
|
|
1979
|
+
Cost digest{" "}
|
|
1980
|
+
<span style={{ fg: api.theme.current.textMuted }}>(24h · lifetime)</span>
|
|
1981
|
+
</text>
|
|
1982
|
+
<Show
|
|
1983
|
+
when={!failed && rows.length > 0}
|
|
1984
|
+
fallback={
|
|
1985
|
+
<text style={{ fg: api.theme.current.textMuted }}>
|
|
1986
|
+
{failed ? "Cost data unavailable." : "No assistant cost recorded."}
|
|
1987
|
+
</text>
|
|
1988
|
+
}
|
|
1989
|
+
>
|
|
1990
|
+
<For each={rows}>
|
|
1991
|
+
{(row) => (
|
|
1992
|
+
<box flexDirection="row" gap={2}>
|
|
1993
|
+
<text flexShrink={0} style={{ fg: api.theme.current.success }}>
|
|
1994
|
+
{money(row.window)}
|
|
1995
|
+
</text>
|
|
1996
|
+
<text flexShrink={0} style={{ fg: api.theme.current.textMuted }}>
|
|
1997
|
+
{money(row.lifetime)}
|
|
1998
|
+
</text>
|
|
1999
|
+
<text flexShrink={0} style={{ fg: api.theme.current.textMuted }}>
|
|
2000
|
+
({row.sessions})
|
|
2001
|
+
</text>
|
|
2002
|
+
<text flexGrow={1} flexShrink={1} wrapMode="none">
|
|
2003
|
+
{prettyDir(row.directory, home)}
|
|
2004
|
+
</text>
|
|
2005
|
+
</box>
|
|
2006
|
+
)}
|
|
2007
|
+
</For>
|
|
2008
|
+
</Show>
|
|
2009
|
+
<box flexShrink={0}>
|
|
2010
|
+
<text style={{ fg: api.theme.current.textMuted }}>esc close</text>
|
|
2011
|
+
</box>
|
|
2012
|
+
</box>
|
|
2013
|
+
),
|
|
2014
|
+
() => {
|
|
2015
|
+
disposeKeys()
|
|
2016
|
+
},
|
|
2017
|
+
)
|
|
2018
|
+
api.ui.dialog.setSize("large")
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// Triage dialog: the waiting set as an openable list. Enter opens the
|
|
2022
|
+
// highlighted session; the sidebar section stays the always-visible view.
|
|
2023
|
+
const openNeeds = async () => {
|
|
2024
|
+
if (api.mode.current() !== BASE_MODE) return
|
|
2025
|
+
if (api.renderer.currentFocusedEditor === null) return
|
|
2026
|
+
let items: { entry: Entry; reason: string }[] = []
|
|
2027
|
+
try {
|
|
2028
|
+
const { entries } = await fetchEntries(api)
|
|
2029
|
+
const db = await openTranscriptDb()
|
|
2030
|
+
let waiting = new Map<string, string>()
|
|
2031
|
+
if (db) {
|
|
2032
|
+
try {
|
|
2033
|
+
waiting = await queryWaitingIds(db)
|
|
2034
|
+
} finally {
|
|
2035
|
+
db.close()
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
items = entries
|
|
2039
|
+
.filter((entry) => waiting.has(entry.id))
|
|
2040
|
+
.map((entry) => ({
|
|
2041
|
+
entry,
|
|
2042
|
+
reason: waiting.get(entry.id) === "question" ? "awaiting answer" : "run stuck",
|
|
2043
|
+
}))
|
|
2044
|
+
} catch {
|
|
2045
|
+
api.ui.toast({ message: "Could not load sessions", variant: "error" })
|
|
2046
|
+
return
|
|
2047
|
+
}
|
|
2048
|
+
if (items.length === 0) {
|
|
2049
|
+
api.ui.toast({ message: "No sessions are waiting on you.", variant: "info" })
|
|
2050
|
+
return
|
|
2051
|
+
}
|
|
2052
|
+
const [cursor, setCursor] = createSignal(0)
|
|
2053
|
+
const moveCursor = (delta: number) =>
|
|
2054
|
+
setCursor((value) => Math.max(0, Math.min(items.length - 1, value + delta)))
|
|
2055
|
+
const choose = () => {
|
|
2056
|
+
const target = items[cursor()]
|
|
2057
|
+
if (!target) return
|
|
2058
|
+
disposeNav()
|
|
2059
|
+
api.ui.dialog.clear()
|
|
2060
|
+
api.route.navigate("session", { sessionID: target.entry.id })
|
|
2061
|
+
}
|
|
2062
|
+
const disposeNav = api.keymap.registerLayer({
|
|
2063
|
+
bindings: [
|
|
2064
|
+
{ key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
|
|
2065
|
+
{ key: "down", desc: "Next session", preventDefault: true, cmd: () => moveCursor(1) },
|
|
2066
|
+
{ key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
|
|
2067
|
+
{
|
|
2068
|
+
key: "escape",
|
|
2069
|
+
desc: "Close",
|
|
2070
|
+
preventDefault: true,
|
|
2071
|
+
cmd: () => api.ui.dialog.clear(),
|
|
2072
|
+
},
|
|
2073
|
+
],
|
|
2074
|
+
})
|
|
2075
|
+
const home = process.env.HOME ?? ""
|
|
2076
|
+
api.ui.dialog.replace(
|
|
2077
|
+
() => (
|
|
2078
|
+
<box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1}>
|
|
2079
|
+
<text attributes={TextAttributes.BOLD}>
|
|
2080
|
+
Needs input{" "}
|
|
2081
|
+
<span style={{ fg: api.theme.current.textMuted }}>({items.length})</span>
|
|
2082
|
+
</text>
|
|
2083
|
+
<box flexDirection="column">
|
|
2084
|
+
<For each={items}>
|
|
2085
|
+
{(item, index) => (
|
|
2086
|
+
<box
|
|
2087
|
+
flexDirection="row"
|
|
2088
|
+
gap={2}
|
|
2089
|
+
paddingLeft={1}
|
|
2090
|
+
paddingRight={1}
|
|
2091
|
+
backgroundColor={
|
|
2092
|
+
index() === cursor()
|
|
2093
|
+
? api.theme.current.primary
|
|
2094
|
+
: RGBA.fromInts(0, 0, 0, 0)
|
|
2095
|
+
}
|
|
2096
|
+
onMouseDown={() => {
|
|
2097
|
+
setCursor(index())
|
|
2098
|
+
choose()
|
|
2099
|
+
}}
|
|
2100
|
+
>
|
|
2101
|
+
<text
|
|
2102
|
+
flexGrow={1}
|
|
2103
|
+
flexShrink={1}
|
|
2104
|
+
wrapMode="none"
|
|
2105
|
+
style={{
|
|
2106
|
+
fg:
|
|
2107
|
+
index() === cursor()
|
|
2108
|
+
? api.theme.current.selectedListItemText
|
|
2109
|
+
: api.theme.current.text,
|
|
2110
|
+
}}
|
|
2111
|
+
>
|
|
2112
|
+
{truncate(item.entry.title, 48)}
|
|
2113
|
+
</text>
|
|
2114
|
+
<text
|
|
2115
|
+
flexShrink={0}
|
|
2116
|
+
style={{
|
|
2117
|
+
fg:
|
|
2118
|
+
index() === cursor()
|
|
2119
|
+
? api.theme.current.selectedListItemText
|
|
2120
|
+
: api.theme.current.warning,
|
|
2121
|
+
}}
|
|
2122
|
+
>
|
|
2123
|
+
{item.reason}
|
|
2124
|
+
</text>
|
|
2125
|
+
<text
|
|
2126
|
+
flexShrink={0}
|
|
2127
|
+
style={{
|
|
2128
|
+
fg:
|
|
2129
|
+
index() === cursor()
|
|
2130
|
+
? api.theme.current.selectedListItemText
|
|
2131
|
+
: api.theme.current.textMuted,
|
|
2132
|
+
}}
|
|
2133
|
+
>
|
|
2134
|
+
{prettyDir(item.entry.dir, home)} · {ago(item.entry.updated)}
|
|
2135
|
+
</text>
|
|
2136
|
+
</box>
|
|
2137
|
+
)}
|
|
2138
|
+
</For>
|
|
2139
|
+
</box>
|
|
2140
|
+
<box flexShrink={0}>
|
|
2141
|
+
<text style={{ fg: api.theme.current.textMuted }}>↑↓ navigate · enter open · esc close</text>
|
|
2142
|
+
</box>
|
|
2143
|
+
</box>
|
|
2144
|
+
),
|
|
2145
|
+
() => {
|
|
2146
|
+
disposeNav()
|
|
2147
|
+
},
|
|
2148
|
+
)
|
|
2149
|
+
api.ui.dialog.setSize("large")
|
|
2150
|
+
}
|
|
2151
|
+
|
|
1909
2152
|
api.command?.register(() => [
|
|
1910
2153
|
{
|
|
1911
2154
|
title: "Pick session (all directories)",
|
|
@@ -1917,6 +2160,26 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1917
2160
|
void openPicker()
|
|
1918
2161
|
},
|
|
1919
2162
|
},
|
|
2163
|
+
{
|
|
2164
|
+
title: "Cost digest (per project)",
|
|
2165
|
+
value: "sesh.costs",
|
|
2166
|
+
description: "Spend per project, last day and lifetime",
|
|
2167
|
+
category: "Sessions",
|
|
2168
|
+
slash: { name: "sesh-costs" },
|
|
2169
|
+
onSelect: () => {
|
|
2170
|
+
void openCosts()
|
|
2171
|
+
},
|
|
2172
|
+
},
|
|
2173
|
+
{
|
|
2174
|
+
title: "Sessions waiting on you",
|
|
2175
|
+
value: "sesh.needs",
|
|
2176
|
+
description: "Unanswered questions and stuck runs",
|
|
2177
|
+
category: "Sessions",
|
|
2178
|
+
slash: { name: "sesh-needs" },
|
|
2179
|
+
onSelect: () => {
|
|
2180
|
+
void openNeeds()
|
|
2181
|
+
},
|
|
2182
|
+
},
|
|
1920
2183
|
])
|
|
1921
2184
|
|
|
1922
2185
|
api.keymap.registerLayer({
|