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 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.4.0...HEAD
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
6
6
  "license": "MIT",
@@ -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({