opencode-sesh 0.4.0 → 0.6.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 +14 -1
- package/CHANGELOG.md +22 -1
- package/README.md +6 -1
- package/package.json +1 -1
- package/tui/sesh-panel.tsx +362 -3
package/AGENTS.md
CHANGED
|
@@ -147,6 +147,17 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
147
147
|
Sessions waiting on the user (unanswered questions, stuck runs) pin a
|
|
148
148
|
**Needs input** group above the directories, with the count in the heading —
|
|
149
149
|
same `NEEDS_INPUT_SQL` heuristic as `bin/sesh-waiting.sh`.
|
|
150
|
+
- **Presence / click-to-focus (cmux-only).** Each sidebar pane heartbeats
|
|
151
|
+
`{workspace, surface, updated}` to the session file
|
|
152
|
+
`${XDG_DATA_HOME:-~/.local/share}/sesh/presence/<sessionId>.json` every poll,
|
|
153
|
+
but only when `CMUX_WORKSPACE_ID` is set; without it nothing is written and a
|
|
154
|
+
click always navigates locally. Opening a session reads that file and, when a
|
|
155
|
+
*fresh* (≤45 s), *foreign* workspace claims it, runs
|
|
156
|
+
`cmux workspace select <id>` instead of `api.route.navigate`. One file per
|
|
157
|
+
session means concurrent panes never share a file; writes are tmp+rename and
|
|
158
|
+
stale files are swept on each poll. A crashed pane falls through to a local
|
|
159
|
+
open after the TTL. The dispatch lives in the sidebar only — the picker and
|
|
160
|
+
home list always open locally.
|
|
150
161
|
- **Picker:** a custom dialog (not `DialogSelect`) grouped by
|
|
151
162
|
project/directory, recency-ordered, resume via
|
|
152
163
|
`api.route.navigate("session", …)`. `ctrl+o` and the command palette open it;
|
|
@@ -179,7 +190,9 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
179
190
|
`slash: { name: "sesh" }` on the `api.command.register` entry — that is the
|
|
180
191
|
only way to open the dialog. A markdown command under `commands/` cannot drive
|
|
181
192
|
the picker (it just prompts the model), so never ship one; the installer
|
|
182
|
-
removes any leftover `commands/sesh.md`.
|
|
193
|
+
removes any leftover `commands/sesh.md`. The command API never passes slash
|
|
194
|
+
arguments, so subcommands are separate registrations (`/sesh-costs`,
|
|
195
|
+
`/sesh-needs`) — all read-only. Destructive actions stay CLI-only.
|
|
183
196
|
- Do not bind `<leader>l` (native `session_list`) or shadow the native
|
|
184
197
|
`/sessions` command.
|
|
185
198
|
- **The sidebar never binds arrow keys globally.** Keyboard navigation is
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-09-24
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Clicking (or Enter on) a sidebar session now switches to the live cmux
|
|
15
|
+
workspace already showing it, instead of opening a duplicate in the current
|
|
16
|
+
pane. Panes advertise their open session (cmux-only, one heartbeat file per
|
|
17
|
+
session, ~45 s TTL); outside cmux or with no live claimant, the click opens
|
|
18
|
+
the session locally as before.
|
|
19
|
+
## [0.5.0] - 2026-09-24
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `/sesh-costs` opens the per-project cost digest inside the TUI (24h beside
|
|
24
|
+
lifetime, same message-level sums as `sesh costs`).
|
|
25
|
+
- `/sesh-needs` opens the sessions waiting on you as an openable list (Enter
|
|
26
|
+
opens, Esc closes). Both new slashes are read-only; prune and retitle stay
|
|
27
|
+
CLI-only.
|
|
28
|
+
|
|
10
29
|
## [0.4.0] - 2026-09-24
|
|
11
30
|
|
|
12
31
|
### Added
|
|
@@ -258,7 +277,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
258
277
|
- Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
|
|
259
278
|
CI.
|
|
260
279
|
|
|
261
|
-
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.
|
|
280
|
+
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.6.0...HEAD
|
|
281
|
+
[0.6.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.5.0...v0.6.0
|
|
282
|
+
[0.5.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.4.0...v0.5.0
|
|
262
283
|
[0.4.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.3.0...v0.4.0
|
|
263
284
|
[0.3.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.1...v0.3.0
|
|
264
285
|
[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 |
|
|
@@ -186,7 +188,10 @@ Sessions waiting on you (unanswered agent questions, runs stuck mid-tool) pin
|
|
|
186
188
|
themselves to a **Needs input** group above the directories, with the count in
|
|
187
189
|
the heading; `sesh --needs-input` lists the same set in the terminal.
|
|
188
190
|
Hovering a row while keyboard navigation is active selects it for the next
|
|
189
|
-
shortcut.
|
|
191
|
+
shortcut. Clicking (or Enter on) a session opens it here — unless a live cmux
|
|
192
|
+
workspace is already showing that session, in which case the sidebar focuses
|
|
193
|
+
that workspace instead of opening a duplicate. The full picker is one
|
|
194
|
+
keystroke away.
|
|
190
195
|
|
|
191
196
|
The full picker pages through your whole global session list (no fixed window)
|
|
192
197
|
and indexes transcript text for every session in the background, showing
|
package/package.json
CHANGED
package/tui/sesh-panel.tsx
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
3
3
|
import type { Project, Session } from "@opencode-ai/sdk/v2"
|
|
4
4
|
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
|
5
|
-
import { mkdir, readFile, rename, rmdir, unlink, writeFile } from "node:fs/promises"
|
|
5
|
+
import { mkdir, readFile, readdir, rename, rmdir, unlink, writeFile } from "node:fs/promises"
|
|
6
6
|
import { dirname, join } from "node:path"
|
|
7
|
+
import { execFile } from "node:child_process"
|
|
8
|
+
import { promisify } from "node:util"
|
|
7
9
|
import { getTreeSitterClient, RGBA, SyntaxStyle, TextAttributes } from "@opentui/core"
|
|
8
10
|
|
|
9
11
|
type ThemeColors = TuiPluginApi["theme"]["current"]
|
|
@@ -388,6 +390,51 @@ async function queryWaitingIds(db: any): Promise<Map<string, string>> {
|
|
|
388
390
|
return out
|
|
389
391
|
}
|
|
390
392
|
|
|
393
|
+
// Presence registry: which cmux workspace is showing which session, so opening
|
|
394
|
+
// a session that is already visible elsewhere focuses that workspace instead
|
|
395
|
+
// of opening a duplicate here. Each panel heartbeats its own session file
|
|
396
|
+
// (one file per session: concurrent panes never share a file, and renames are
|
|
397
|
+
// atomic, so no locking is needed); files older than PRESENCE_TTL_MS are
|
|
398
|
+
// treated as gone. Outside cmux nothing is written and nothing is read.
|
|
399
|
+
const PRESENCE_TTL_MS = 45000
|
|
400
|
+
function presenceDir(): string {
|
|
401
|
+
const home = process.env.HOME ?? ""
|
|
402
|
+
const dataHome = process.env.XDG_DATA_HOME ?? `${home}/.local/share`
|
|
403
|
+
return join(dataHome, "sesh", "presence")
|
|
404
|
+
}
|
|
405
|
+
function parsePresence(raw: string, now: number): string | undefined {
|
|
406
|
+
let parsed: { workspace?: unknown; updated?: unknown }
|
|
407
|
+
try {
|
|
408
|
+
parsed = JSON.parse(raw)
|
|
409
|
+
} catch {
|
|
410
|
+
return undefined
|
|
411
|
+
}
|
|
412
|
+
if (typeof parsed?.workspace !== "string" || !parsed.workspace) return undefined
|
|
413
|
+
if (typeof parsed?.updated !== "number" || now - parsed.updated > PRESENCE_TTL_MS) return undefined
|
|
414
|
+
return parsed.workspace
|
|
415
|
+
}
|
|
416
|
+
async function sweepPresence(): Promise<void> {
|
|
417
|
+
try {
|
|
418
|
+
const dir = presenceDir()
|
|
419
|
+
const now = Date.now()
|
|
420
|
+
const names = await readdir(dir)
|
|
421
|
+
await Promise.all(
|
|
422
|
+
names
|
|
423
|
+
.filter((name) => name.endsWith(".json"))
|
|
424
|
+
.map(async (name) => {
|
|
425
|
+
try {
|
|
426
|
+
const raw = await readFile(join(dir, name), "utf8")
|
|
427
|
+
if (!parsePresence(raw, now)) await unlink(join(dir, name))
|
|
428
|
+
} catch {
|
|
429
|
+
// ignore races with other panes
|
|
430
|
+
}
|
|
431
|
+
}),
|
|
432
|
+
)
|
|
433
|
+
} catch {
|
|
434
|
+
// missing directory or concurrent cleanup; presence is best-effort
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
391
438
|
function addTranscript(index: Map<string, string>, sid: string, data: string): void {
|
|
392
439
|
try {
|
|
393
440
|
const part = JSON.parse(data) as { type?: unknown; text?: unknown }
|
|
@@ -696,6 +743,30 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
696
743
|
} catch {
|
|
697
744
|
// leave the previous waiting set in place rather than flashing
|
|
698
745
|
}
|
|
746
|
+
// Heartbeat this pane's session so another pane can focus it instead of
|
|
747
|
+
// opening a duplicate. cmux-only: without CMUX_WORKSPACE_ID nothing is
|
|
748
|
+
// written and opening a session always navigates locally.
|
|
749
|
+
try {
|
|
750
|
+
const workspace = process.env.CMUX_WORKSPACE_ID
|
|
751
|
+
const id = currentID()
|
|
752
|
+
if (workspace && id && SESSION_ID_PATTERN.test(id)) {
|
|
753
|
+
const dir = presenceDir()
|
|
754
|
+
await mkdir(dir, { recursive: true })
|
|
755
|
+
const tmp = join(dir, `${id}.tmp`)
|
|
756
|
+
await writeFile(
|
|
757
|
+
tmp,
|
|
758
|
+
JSON.stringify({
|
|
759
|
+
workspace,
|
|
760
|
+
surface: process.env.CMUX_SURFACE_ID ?? null,
|
|
761
|
+
updated: Date.now(),
|
|
762
|
+
}),
|
|
763
|
+
)
|
|
764
|
+
await rename(tmp, join(dir, `${id}.json`))
|
|
765
|
+
}
|
|
766
|
+
} catch {
|
|
767
|
+
// presence is best-effort; a failed heartbeat only disables focusing
|
|
768
|
+
}
|
|
769
|
+
if (process.env.CMUX_WORKSPACE_ID) void sweepPresence()
|
|
699
770
|
}
|
|
700
771
|
void load()
|
|
701
772
|
const clock = setInterval(() => void load(), POLL_MS)
|
|
@@ -774,6 +845,31 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
774
845
|
setSectionCollapsed((value) => !value)
|
|
775
846
|
}
|
|
776
847
|
|
|
848
|
+
// Open a session. If a live cmux workspace is already showing it, focus that
|
|
849
|
+
// workspace instead of opening a duplicate here. The heartbeat self-entry is
|
|
850
|
+
// ignored (it is this pane); a stale file (crashed pane) falls through to a
|
|
851
|
+
// local navigate. Only the sidebar goes through here.
|
|
852
|
+
const openSession = async (entry: Entry) => {
|
|
853
|
+
const selfWorkspace = process.env.CMUX_WORKSPACE_ID
|
|
854
|
+
if (selfWorkspace) {
|
|
855
|
+
try {
|
|
856
|
+
const raw = await readFile(join(presenceDir(), `${entry.id}.json`), "utf8")
|
|
857
|
+
const workspace = parsePresence(raw, Date.now())
|
|
858
|
+
if (workspace && workspace !== selfWorkspace) {
|
|
859
|
+
try {
|
|
860
|
+
await promisify(execFile)("cmux", ["workspace", "select", workspace])
|
|
861
|
+
return
|
|
862
|
+
} catch {
|
|
863
|
+
// cmux unavailable; fall through to a local open
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
} catch {
|
|
867
|
+
// no presence file for this session
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
props.api.route.navigate("session", { sessionID: entry.id })
|
|
871
|
+
}
|
|
872
|
+
|
|
777
873
|
// Flat order of the rows a cursor can land on, so keyboard navigation and
|
|
778
874
|
// mouse hover resolve to the same row.
|
|
779
875
|
const itemRows = createMemo(() => tree().flatMap((row) => (row.kind === "item" ? [row.entry] : [])))
|
|
@@ -1007,7 +1103,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1007
1103
|
preventDefault: true,
|
|
1008
1104
|
cmd: () => {
|
|
1009
1105
|
const entry = itemRows()[cursor()]
|
|
1010
|
-
if (entry)
|
|
1106
|
+
if (entry) void openSession(entry)
|
|
1011
1107
|
},
|
|
1012
1108
|
},
|
|
1013
1109
|
{
|
|
@@ -1138,7 +1234,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1138
1234
|
}
|
|
1139
1235
|
}}
|
|
1140
1236
|
onMouseOut={() => setHovered(undefined)}
|
|
1141
|
-
onMouseDown={() =>
|
|
1237
|
+
onMouseDown={() => void openSession(row.entry)}
|
|
1142
1238
|
>
|
|
1143
1239
|
<text flexShrink={0}>
|
|
1144
1240
|
<span style={{ fg: theme().textMuted }}>{row.last ? "└" : "├"}</span>
|
|
@@ -1906,6 +2002,249 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1906
2002
|
api.ui.dialog.setSize("xlarge")
|
|
1907
2003
|
}
|
|
1908
2004
|
|
|
2005
|
+
// Read-only digest dialog: spend per project, last day beside lifetime.
|
|
2006
|
+
// Same message-level sums as `sesh costs`; the dialog just renders them.
|
|
2007
|
+
const openCosts = async () => {
|
|
2008
|
+
if (api.mode.current() !== BASE_MODE) return
|
|
2009
|
+
if (api.renderer.currentFocusedEditor === null) return
|
|
2010
|
+
type CostRow = { directory: string; window: number; lifetime: number; sessions: number }
|
|
2011
|
+
let rows: CostRow[] = []
|
|
2012
|
+
let failed = false
|
|
2013
|
+
try {
|
|
2014
|
+
const db = await openTranscriptDb()
|
|
2015
|
+
if (!db) failed = true
|
|
2016
|
+
else {
|
|
2017
|
+
try {
|
|
2018
|
+
const cutoff = Date.now() - 86400000
|
|
2019
|
+
const raw = (db.query(`
|
|
2020
|
+
SELECT s.directory AS directory,
|
|
2021
|
+
ROUND(SUM(CASE WHEN m.time_created >= ${cutoff} THEN COALESCE(json_extract(m.data, '$.cost'), 0) ELSE 0 END), 4) AS window_cost,
|
|
2022
|
+
ROUND(SUM(COALESCE(json_extract(m.data, '$.cost'), 0)), 4) AS lifetime_cost,
|
|
2023
|
+
COUNT(DISTINCT s.id) AS sessions
|
|
2024
|
+
FROM message m
|
|
2025
|
+
JOIN session s ON s.id = m.session_id
|
|
2026
|
+
WHERE json_extract(m.data, '$.role') = 'assistant'
|
|
2027
|
+
AND json_extract(m.data, '$.cost') IS NOT NULL
|
|
2028
|
+
GROUP BY s.directory
|
|
2029
|
+
ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
2030
|
+
directory: unknown
|
|
2031
|
+
window_cost: unknown
|
|
2032
|
+
lifetime_cost: unknown
|
|
2033
|
+
sessions: unknown
|
|
2034
|
+
}[]
|
|
2035
|
+
rows = raw
|
|
2036
|
+
.filter(
|
|
2037
|
+
(
|
|
2038
|
+
row,
|
|
2039
|
+
): row is {
|
|
2040
|
+
directory: string
|
|
2041
|
+
window_cost: number
|
|
2042
|
+
lifetime_cost: number
|
|
2043
|
+
sessions: number
|
|
2044
|
+
} =>
|
|
2045
|
+
typeof row?.directory === "string" &&
|
|
2046
|
+
typeof row?.window_cost === "number" &&
|
|
2047
|
+
typeof row?.lifetime_cost === "number" &&
|
|
2048
|
+
typeof row?.sessions === "number" &&
|
|
2049
|
+
row.lifetime_cost > 0,
|
|
2050
|
+
)
|
|
2051
|
+
.map((row) => ({
|
|
2052
|
+
directory: row.directory,
|
|
2053
|
+
window: row.window_cost,
|
|
2054
|
+
lifetime: row.lifetime_cost,
|
|
2055
|
+
sessions: row.sessions,
|
|
2056
|
+
}))
|
|
2057
|
+
} finally {
|
|
2058
|
+
db.close()
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
} catch {
|
|
2062
|
+
failed = true
|
|
2063
|
+
}
|
|
2064
|
+
const money = (n: number) => (n === 0 ? "—" : `$${n.toFixed(2)}`)
|
|
2065
|
+
const home = process.env.HOME ?? ""
|
|
2066
|
+
const disposeKeys = api.keymap.registerLayer({
|
|
2067
|
+
bindings: [
|
|
2068
|
+
{ key: "escape", desc: "Close", preventDefault: true, cmd: () => api.ui.dialog.clear() },
|
|
2069
|
+
],
|
|
2070
|
+
})
|
|
2071
|
+
api.ui.dialog.replace(
|
|
2072
|
+
() => (
|
|
2073
|
+
<box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1}>
|
|
2074
|
+
<text attributes={TextAttributes.BOLD}>
|
|
2075
|
+
Cost digest{" "}
|
|
2076
|
+
<span style={{ fg: api.theme.current.textMuted }}>(24h · lifetime)</span>
|
|
2077
|
+
</text>
|
|
2078
|
+
<Show
|
|
2079
|
+
when={!failed && rows.length > 0}
|
|
2080
|
+
fallback={
|
|
2081
|
+
<text style={{ fg: api.theme.current.textMuted }}>
|
|
2082
|
+
{failed ? "Cost data unavailable." : "No assistant cost recorded."}
|
|
2083
|
+
</text>
|
|
2084
|
+
}
|
|
2085
|
+
>
|
|
2086
|
+
<For each={rows}>
|
|
2087
|
+
{(row) => (
|
|
2088
|
+
<box flexDirection="row" gap={2}>
|
|
2089
|
+
<text flexShrink={0} style={{ fg: api.theme.current.success }}>
|
|
2090
|
+
{money(row.window)}
|
|
2091
|
+
</text>
|
|
2092
|
+
<text flexShrink={0} style={{ fg: api.theme.current.textMuted }}>
|
|
2093
|
+
{money(row.lifetime)}
|
|
2094
|
+
</text>
|
|
2095
|
+
<text flexShrink={0} style={{ fg: api.theme.current.textMuted }}>
|
|
2096
|
+
({row.sessions})
|
|
2097
|
+
</text>
|
|
2098
|
+
<text flexGrow={1} flexShrink={1} wrapMode="none">
|
|
2099
|
+
{prettyDir(row.directory, home)}
|
|
2100
|
+
</text>
|
|
2101
|
+
</box>
|
|
2102
|
+
)}
|
|
2103
|
+
</For>
|
|
2104
|
+
</Show>
|
|
2105
|
+
<box flexShrink={0}>
|
|
2106
|
+
<text style={{ fg: api.theme.current.textMuted }}>esc close</text>
|
|
2107
|
+
</box>
|
|
2108
|
+
</box>
|
|
2109
|
+
),
|
|
2110
|
+
() => {
|
|
2111
|
+
disposeKeys()
|
|
2112
|
+
},
|
|
2113
|
+
)
|
|
2114
|
+
api.ui.dialog.setSize("large")
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
// Triage dialog: the waiting set as an openable list. Enter opens the
|
|
2118
|
+
// highlighted session; the sidebar section stays the always-visible view.
|
|
2119
|
+
const openNeeds = async () => {
|
|
2120
|
+
if (api.mode.current() !== BASE_MODE) return
|
|
2121
|
+
if (api.renderer.currentFocusedEditor === null) return
|
|
2122
|
+
let items: { entry: Entry; reason: string }[] = []
|
|
2123
|
+
try {
|
|
2124
|
+
const { entries } = await fetchEntries(api)
|
|
2125
|
+
const db = await openTranscriptDb()
|
|
2126
|
+
let waiting = new Map<string, string>()
|
|
2127
|
+
if (db) {
|
|
2128
|
+
try {
|
|
2129
|
+
waiting = await queryWaitingIds(db)
|
|
2130
|
+
} finally {
|
|
2131
|
+
db.close()
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
items = entries
|
|
2135
|
+
.filter((entry) => waiting.has(entry.id))
|
|
2136
|
+
.map((entry) => ({
|
|
2137
|
+
entry,
|
|
2138
|
+
reason: waiting.get(entry.id) === "question" ? "awaiting answer" : "run stuck",
|
|
2139
|
+
}))
|
|
2140
|
+
} catch {
|
|
2141
|
+
api.ui.toast({ message: "Could not load sessions", variant: "error" })
|
|
2142
|
+
return
|
|
2143
|
+
}
|
|
2144
|
+
if (items.length === 0) {
|
|
2145
|
+
api.ui.toast({ message: "No sessions are waiting on you.", variant: "info" })
|
|
2146
|
+
return
|
|
2147
|
+
}
|
|
2148
|
+
const [cursor, setCursor] = createSignal(0)
|
|
2149
|
+
const moveCursor = (delta: number) =>
|
|
2150
|
+
setCursor((value) => Math.max(0, Math.min(items.length - 1, value + delta)))
|
|
2151
|
+
const choose = () => {
|
|
2152
|
+
const target = items[cursor()]
|
|
2153
|
+
if (!target) return
|
|
2154
|
+
disposeNav()
|
|
2155
|
+
api.ui.dialog.clear()
|
|
2156
|
+
api.route.navigate("session", { sessionID: target.entry.id })
|
|
2157
|
+
}
|
|
2158
|
+
const disposeNav = api.keymap.registerLayer({
|
|
2159
|
+
bindings: [
|
|
2160
|
+
{ key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
|
|
2161
|
+
{ key: "down", desc: "Next session", preventDefault: true, cmd: () => moveCursor(1) },
|
|
2162
|
+
{ key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
|
|
2163
|
+
{
|
|
2164
|
+
key: "escape",
|
|
2165
|
+
desc: "Close",
|
|
2166
|
+
preventDefault: true,
|
|
2167
|
+
cmd: () => api.ui.dialog.clear(),
|
|
2168
|
+
},
|
|
2169
|
+
],
|
|
2170
|
+
})
|
|
2171
|
+
const home = process.env.HOME ?? ""
|
|
2172
|
+
api.ui.dialog.replace(
|
|
2173
|
+
() => (
|
|
2174
|
+
<box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1}>
|
|
2175
|
+
<text attributes={TextAttributes.BOLD}>
|
|
2176
|
+
Needs input{" "}
|
|
2177
|
+
<span style={{ fg: api.theme.current.textMuted }}>({items.length})</span>
|
|
2178
|
+
</text>
|
|
2179
|
+
<box flexDirection="column">
|
|
2180
|
+
<For each={items}>
|
|
2181
|
+
{(item, index) => (
|
|
2182
|
+
<box
|
|
2183
|
+
flexDirection="row"
|
|
2184
|
+
gap={2}
|
|
2185
|
+
paddingLeft={1}
|
|
2186
|
+
paddingRight={1}
|
|
2187
|
+
backgroundColor={
|
|
2188
|
+
index() === cursor()
|
|
2189
|
+
? api.theme.current.primary
|
|
2190
|
+
: RGBA.fromInts(0, 0, 0, 0)
|
|
2191
|
+
}
|
|
2192
|
+
onMouseDown={() => {
|
|
2193
|
+
setCursor(index())
|
|
2194
|
+
choose()
|
|
2195
|
+
}}
|
|
2196
|
+
>
|
|
2197
|
+
<text
|
|
2198
|
+
flexGrow={1}
|
|
2199
|
+
flexShrink={1}
|
|
2200
|
+
wrapMode="none"
|
|
2201
|
+
style={{
|
|
2202
|
+
fg:
|
|
2203
|
+
index() === cursor()
|
|
2204
|
+
? api.theme.current.selectedListItemText
|
|
2205
|
+
: api.theme.current.text,
|
|
2206
|
+
}}
|
|
2207
|
+
>
|
|
2208
|
+
{truncate(item.entry.title, 48)}
|
|
2209
|
+
</text>
|
|
2210
|
+
<text
|
|
2211
|
+
flexShrink={0}
|
|
2212
|
+
style={{
|
|
2213
|
+
fg:
|
|
2214
|
+
index() === cursor()
|
|
2215
|
+
? api.theme.current.selectedListItemText
|
|
2216
|
+
: api.theme.current.warning,
|
|
2217
|
+
}}
|
|
2218
|
+
>
|
|
2219
|
+
{item.reason}
|
|
2220
|
+
</text>
|
|
2221
|
+
<text
|
|
2222
|
+
flexShrink={0}
|
|
2223
|
+
style={{
|
|
2224
|
+
fg:
|
|
2225
|
+
index() === cursor()
|
|
2226
|
+
? api.theme.current.selectedListItemText
|
|
2227
|
+
: api.theme.current.textMuted,
|
|
2228
|
+
}}
|
|
2229
|
+
>
|
|
2230
|
+
{prettyDir(item.entry.dir, home)} · {ago(item.entry.updated)}
|
|
2231
|
+
</text>
|
|
2232
|
+
</box>
|
|
2233
|
+
)}
|
|
2234
|
+
</For>
|
|
2235
|
+
</box>
|
|
2236
|
+
<box flexShrink={0}>
|
|
2237
|
+
<text style={{ fg: api.theme.current.textMuted }}>↑↓ navigate · enter open · esc close</text>
|
|
2238
|
+
</box>
|
|
2239
|
+
</box>
|
|
2240
|
+
),
|
|
2241
|
+
() => {
|
|
2242
|
+
disposeNav()
|
|
2243
|
+
},
|
|
2244
|
+
)
|
|
2245
|
+
api.ui.dialog.setSize("large")
|
|
2246
|
+
}
|
|
2247
|
+
|
|
1909
2248
|
api.command?.register(() => [
|
|
1910
2249
|
{
|
|
1911
2250
|
title: "Pick session (all directories)",
|
|
@@ -1917,6 +2256,26 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1917
2256
|
void openPicker()
|
|
1918
2257
|
},
|
|
1919
2258
|
},
|
|
2259
|
+
{
|
|
2260
|
+
title: "Cost digest (per project)",
|
|
2261
|
+
value: "sesh.costs",
|
|
2262
|
+
description: "Spend per project, last day and lifetime",
|
|
2263
|
+
category: "Sessions",
|
|
2264
|
+
slash: { name: "sesh-costs" },
|
|
2265
|
+
onSelect: () => {
|
|
2266
|
+
void openCosts()
|
|
2267
|
+
},
|
|
2268
|
+
},
|
|
2269
|
+
{
|
|
2270
|
+
title: "Sessions waiting on you",
|
|
2271
|
+
value: "sesh.needs",
|
|
2272
|
+
description: "Unanswered questions and stuck runs",
|
|
2273
|
+
category: "Sessions",
|
|
2274
|
+
slash: { name: "sesh-needs" },
|
|
2275
|
+
onSelect: () => {
|
|
2276
|
+
void openNeeds()
|
|
2277
|
+
},
|
|
2278
|
+
},
|
|
1920
2279
|
])
|
|
1921
2280
|
|
|
1922
2281
|
api.keymap.registerLayer({
|