opencode-sesh 0.2.0 → 0.2.1
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 +18 -9
- package/CHANGELOG.md +49 -1
- package/README.md +14 -5
- package/bin/sesh-list.sh +12 -9
- package/bin/sesh-pins.sh +55 -0
- package/bin/sesh-shortcuts.sh +3 -1
- package/bin/sesh.sh +6 -1
- package/package.json +1 -1
- package/tui/sesh-panel.tsx +298 -94
package/AGENTS.md
CHANGED
|
@@ -72,6 +72,11 @@ the schema below. Keystrokes never touch the database:
|
|
|
72
72
|
selection leaves a one-shot `STATE_DIR/action-notice` the next render shows
|
|
73
73
|
and consumes. Snapshot records carry `archived` as 0/1 (the DB column is an
|
|
74
74
|
epoch, not a boolean).
|
|
75
|
+
- Session and directory pins live in `${XDG_DATA_HOME:-$HOME/.local/share}/sesh/pins.json`
|
|
76
|
+
(`SESH_PINS_FILE` overrides it for tests), shared by fzf and the TUI. Writes
|
|
77
|
+
use a sibling lock directory and atomic rename. Option-S toggles a selected
|
|
78
|
+
session; Option-D toggles its directory. Pinned directories sort before groups
|
|
79
|
+
containing pinned sessions, with pinned sessions first inside each group.
|
|
75
80
|
|
|
76
81
|
**Output contract** (TSV, 6 fields):
|
|
77
82
|
`agentId sessionId liveState display cwd trackingId`. `display` is the only
|
|
@@ -125,10 +130,11 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
125
130
|
## TUI panel (`tui/sesh-panel.tsx`)
|
|
126
131
|
|
|
127
132
|
- **Sidebar:** `api.slots.register` on `sidebar_content` (append mode — native
|
|
128
|
-
sidebar content stays). Renders the newest
|
|
129
|
-
with the current one highlighted, polled every 15 s;
|
|
130
|
-
ctrl+x deletes one (armed, then confirmed).
|
|
131
|
-
|
|
133
|
+
sidebar content stays). Renders pinned sessions before the newest unarchived
|
|
134
|
+
sessions, with the current one highlighted, polled every 15 s; Ctrl-P previews
|
|
135
|
+
a row and ctrl+x deletes one (armed, then confirmed). The section lists every
|
|
136
|
+
session without a row cap and flex-grows to fill the sidebar, with overflow
|
|
137
|
+
scrolling inside; the picker remains the place for transcript search.
|
|
132
138
|
- **Picker:** a custom dialog (not `DialogSelect`) grouped by
|
|
133
139
|
project/directory, recency-ordered, resume via
|
|
134
140
|
`api.route.navigate("session", …)`. `ctrl+o` and the command palette open it;
|
|
@@ -168,12 +174,15 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
168
174
|
opt-in: clicking the *Sessions* heading (or its search box) activates a
|
|
169
175
|
priority-20 layer, because a global `↑`/`↓` would hijack the main prompt.
|
|
170
176
|
Cursor state is derived from `itemRows()`, so hover and cursor resolve to the
|
|
171
|
-
same row
|
|
172
|
-
|
|
177
|
+
same row. The section lists every session with no row cap
|
|
178
|
+
(`tests/tui.mjs` asserts the cap stays out); overflow scrolls inside the
|
|
179
|
+
stretched section.
|
|
173
180
|
- **Delete in the TUI is two-step.** The first `ctrl+x` arms `pendingDelete`,
|
|
174
|
-
the second (or `y`, or Enter in the picker footer) commits
|
|
175
|
-
|
|
176
|
-
|
|
181
|
+
the second (or `y`, or Enter in the picker footer) commits the armed row
|
|
182
|
+
wherever the pointer or cursor is, and `n`/Esc (or a five-second timeout)
|
|
183
|
+
cancels. Moving the pointer must never disarm: edge-hover flicker ate the
|
|
184
|
+
confirm before `confirmArmed` landed. The `y`/`n` layer is registered *only
|
|
185
|
+
while armed*, so it can never shadow the search box's typing.
|
|
177
186
|
- **Picker `ctrl+g` is project scope**, not close (parity with the fzf picker's
|
|
178
187
|
`Ctrl-G`); Esc is the only close key. `ctrl+f` forks through
|
|
179
188
|
`api.client.session.fork` and navigates to the returned session.
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.1] - 2026-09-24
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- The TUI sidebar's Sessions section now lists every session and fills the
|
|
15
|
+
available sidebar height: the fixed 15-row cap is gone, with overflow
|
|
16
|
+
scrolling inside the section.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Pin sessions with Option-S or directories with Option-D in both pickers. Pins live
|
|
21
|
+
in a shared XDG data file, appear with a star, and sort ahead of recent
|
|
22
|
+
sessions in the terminal picker, TUI picker, sidebar and home list.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- The sidebar delete confirm no longer disarms when the pointer leaves the
|
|
27
|
+
armed row: the second Ctrl+X (or `y`) commits the visibly armed row wherever
|
|
28
|
+
the pointer is, so deleting works while hovering the sidebar edges.
|
|
29
|
+
|
|
30
|
+
- Session previews now use Ctrl-P rather than Space in the terminal picker,
|
|
31
|
+
sidebar and home list, so typing cannot accidentally open a preview.
|
|
32
|
+
- Hovering a sidebar session while keyboard navigation is active now moves the
|
|
33
|
+
selection to that row, so Option-S/Option-D pin the row under the pointer rather
|
|
34
|
+
than the previously selected (often currently open) session.
|
|
35
|
+
- The TUI sidebar's Sessions section now has a working show/hide control,
|
|
36
|
+
separate from clicking the heading to activate keyboard navigation.
|
|
37
|
+
- Transcript previews pass OpenTUI's tree-sitter client to the markdown
|
|
38
|
+
renderer, restoring themed Markdown and fenced-code highlighting.
|
|
39
|
+
- Preview Markdown now uses the active theme's semantic accents for headings,
|
|
40
|
+
emphasis, code and list markers when the default Markdown colors read like
|
|
41
|
+
body text.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- Pin shortcuts are labeled Option-S/Option-D (the macOS key) in both pickers
|
|
46
|
+
and the docs. The terminal picker still binds fzf's `alt-s`/`alt-d`, which is
|
|
47
|
+
fzf's name for Option and unchanged.
|
|
48
|
+
- The TUI picker now shows transcript search matches as indexing progresses,
|
|
49
|
+
identifies transcript-only hits, and distinguishes an in-progress search from
|
|
50
|
+
a completed search with no results.
|
|
51
|
+
- Session previews identify the selected session and show loading feedback
|
|
52
|
+
while it changes, instead of displaying another session's transcript.
|
|
53
|
+
- The TUI picker uses `Ctrl-P` for transcript preview; Space always enters a
|
|
54
|
+
space in the search field, including at the start of a query.
|
|
55
|
+
- Sidebar and home searches direct users to the full picker for transcript
|
|
56
|
+
search when no titles or directories match.
|
|
10
57
|
## [0.2.0] - 2026-09-21
|
|
11
58
|
|
|
12
59
|
### Added
|
|
@@ -178,7 +225,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
178
225
|
- Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
|
|
179
226
|
CI.
|
|
180
227
|
|
|
181
|
-
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.
|
|
228
|
+
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.1...HEAD
|
|
229
|
+
[0.2.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.0...v0.2.1
|
|
182
230
|
[0.2.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.7...v0.2.0
|
|
183
231
|
[0.1.7]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.6...v0.1.7
|
|
184
232
|
[0.1.6]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.5...v0.1.6
|
package/README.md
CHANGED
|
@@ -27,7 +27,10 @@ grouped by directory, full-text searchable, and previewable from anywhere.
|
|
|
27
27
|
## Why sesh
|
|
28
28
|
|
|
29
29
|
- **Every session, everywhere.** Sessions are grouped by project directory and
|
|
30
|
-
sorted by recency — across *all* your projects, not just the current one.
|
|
30
|
+
sorted by pins, then recency — across *all* your projects, not just the current one.
|
|
31
|
+
- **Pin what matters.** Option-S pins a session and Option-D pins its directory in
|
|
32
|
+
either picker. Pins are shared across the terminal and opencode TUI and
|
|
33
|
+
persist across restarts; pinned directories and sessions sort first.
|
|
31
34
|
- **Full-text search.** Type to match against session titles *and* the text of
|
|
32
35
|
the conversation itself. Reasoning and tool output are excluded, so the index
|
|
33
36
|
stays clean.
|
|
@@ -112,7 +115,8 @@ bash install.sh --uninstall # from source
|
|
|
112
115
|
| Enter | Resume the selected session in this terminal |
|
|
113
116
|
| Ctrl-F | Resume as a fork (the original is untouched) |
|
|
114
117
|
| Ctrl-G | Toggle current-directory scope / all sessions |
|
|
115
|
-
|
|
|
118
|
+
| Option-S / Option-D | Pin or unpin the selected session / directory |
|
|
119
|
+
| Ctrl-P | Toggle the transcript preview |
|
|
116
120
|
| `?` (empty query) | Toggle shortcut help |
|
|
117
121
|
| Ctrl-X | Delete the selected session (asks to confirm) |
|
|
118
122
|
| Escape | Exit |
|
|
@@ -144,18 +148,22 @@ non-interactive `sesh-delete.sh` refuses unless given `--yes`.
|
|
|
144
148
|
| `ctrl+o` | Open the full picker (also in the command palette) |
|
|
145
149
|
| Type | Search titles, directories and transcript text |
|
|
146
150
|
| `↑`/`↓`, `PgUp`/`PgDn`, `Home`/`End` | Move the selection |
|
|
147
|
-
|
|
|
151
|
+
| Ctrl-P | Toggle the transcript preview |
|
|
148
152
|
| Enter | Open the selected session |
|
|
149
153
|
| Ctrl-X | Delete the selected session (asks to confirm) |
|
|
150
154
|
| Ctrl-F | Fork the selected session |
|
|
151
155
|
| Ctrl-G | Toggle scope: the selected session's project, or every project |
|
|
156
|
+
| Option-S / Option-D | Pin or unpin the selected session / directory |
|
|
152
157
|
| Esc | Close |
|
|
153
158
|
|
|
154
159
|
Click the sidebar's `search…` box to filter recent sessions in place; click
|
|
155
160
|
elsewhere or press Esc to leave search. Click the **Sessions** heading to drive
|
|
156
161
|
the list from the keyboard instead of the mouse (`↑`/`↓` to move, Enter to open,
|
|
157
|
-
|
|
158
|
-
|
|
162
|
+
Ctrl-P to preview, Option-S/Option-D to pin, Ctrl-X to delete, Esc to leave). Pinned
|
|
163
|
+
sessions appear ahead of the rest; the section lists every session and scrolls
|
|
164
|
+
to fill the sidebar, so click **▾ hide** to collapse it when you need the room.
|
|
165
|
+
Hovering a row while keyboard navigation is active selects it for the next
|
|
166
|
+
shortcut. The full picker is one keystroke away.
|
|
159
167
|
|
|
160
168
|
The full picker pages through your whole global session list (no fixed window)
|
|
161
169
|
and indexes transcript text for every session in the background, showing
|
|
@@ -174,6 +182,7 @@ All variables are optional.
|
|
|
174
182
|
| `SESH_GLOW` | first `glow` on `PATH` | optional Markdown preview renderer |
|
|
175
183
|
| `SESH_OPENCODE` | `opencode` | opencode executable (resume / delete) |
|
|
176
184
|
| `SESH_CACHE_DIR` | `~/.cache/sesh` | persistent search-index cache |
|
|
185
|
+
| `SESH_PINS_FILE` | `${XDG_DATA_HOME:-~/.local/share}/sesh/pins.json` | shared persistent session/directory pins |
|
|
177
186
|
|
|
178
187
|
## How it works
|
|
179
188
|
|
package/bin/sesh-list.sh
CHANGED
|
@@ -157,7 +157,7 @@ render() {
|
|
|
157
157
|
{
|
|
158
158
|
[ -n "$notice" ] && printf '\t\tunknown\t%s%s%s\t\tnotice:action\n' "$YELLOW" "$notice" "$RESET"
|
|
159
159
|
[ -n "$state" ] && [ "$state" != fresh ] && printf '\t\tunknown\t%s%s%s\t\tnotice:live-state\n' "$YELLOW" "$state" "$RESET"
|
|
160
|
-
"$JQ_BIN" -sr --arg green "$GREEN" --arg cyan "$CYAN" --arg gray "$GRAY" --arg magenta "$MAGENTA" --arg red "$RED" --arg bold "$BOLD" --arg yellow "$YELLOW" --arg reset "$RESET" --arg query "$QUERY" --arg home "$HOME" --arg selected "$SELECTED_ID" '
|
|
160
|
+
"$JQ_BIN" -sr --argjson pins "$("${BASH_SOURCE[0]%/*}/sesh-pins.sh" read)" --arg green "$GREEN" --arg cyan "$CYAN" --arg gray "$GRAY" --arg magenta "$MAGENTA" --arg red "$RED" --arg bold "$BOLD" --arg yellow "$YELLOW" --arg reset "$RESET" --arg query "$QUERY" --arg home "$HOME" --arg selected "$SELECTED_ID" '
|
|
161
161
|
def ago($epoch):
|
|
162
162
|
((now - ($epoch | tonumber)) | floor) as $s
|
|
163
163
|
| if $s < 60 then "now" elif $s < 3600 then "\($s / 60 | floor)m" elif $s < 86400 then "\($s / 3600 | floor)h" else "\($s / 86400 | floor)d" end;
|
|
@@ -180,17 +180,20 @@ render() {
|
|
|
180
180
|
then notice_row(if $q == "" then "No sessions in this store" else "No sessions match \"" + $query + "\"" end; "notice:no-match")
|
|
181
181
|
else empty end),
|
|
182
182
|
($matched
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
183
|
+
| group_by(.cwd)
|
|
184
|
+
| map({cwd: .[0].cwd, updated: (map(.updatedEpoch) | max),
|
|
185
|
+
pinDirectory: (.[0].cwd as $dir | $pins.directories | index($dir) != null),
|
|
186
|
+
pinSession: (any(.[]; .sessionId as $id | $pins.sessions | index($id) != null)),
|
|
187
|
+
items: (sort_by([(.sessionId as $id | $pins.sessions | index($id) != null), .updatedEpoch]) | reverse)})
|
|
188
|
+
| sort_by([.pinDirectory, .pinSession, .updated]) | reverse
|
|
189
|
+
| .[] as $g
|
|
190
|
+
| (["", "", "unknown", ($magenta + (if $g.pinDirectory then "★ " else "" end) + ($g.cwd | homepath | gsub("[\u0000-\u001f\u007f-\u009f]";" ")) + $reset), $g.cwd, ("hdr:" + $g.cwd)] | @tsv),
|
|
188
191
|
($g.items | to_entries[] | .key as $i | .value as $s
|
|
189
192
|
# Archived rows are opt-in and need to look like it; nothing else about
|
|
190
193
|
# a row is knowable here (live agent state is not exposed by opencode).
|
|
191
194
|
| (if $s.archived == 1 then $yellow else $gray end) as $c
|
|
192
195
|
| ($gray + (if $i == (($g.items|length)-1) then "└" else "├" end) + $reset) as $branch
|
|
193
|
-
|
|
196
|
+
| [$s.agentId, $s.sessionId, $s.liveState, ($branch + " " + $c + (if ($s.sessionId as $id | $pins.sessions | index($id)) != null then "★ " else "" end) + ($s.title | gsub("[\u0000-\u001f\u007f-\u009f]";" ")) + (if $s.archived == 1 then " · archived" else "" end) + $reset + " " + $gray + ago($s.updatedEpoch) + $reset), $s.cwd, $s.sessionId] | @tsv)))
|
|
194
197
|
' "$SNAPSHOT"
|
|
195
198
|
} || printf '\t\tunknown\t%sSnapshot rendering failed; retry refresh%s\t\tnotice:render-error\n' "$YELLOW" "$RESET"
|
|
196
199
|
}
|
|
@@ -372,7 +375,7 @@ refresh() {
|
|
|
372
375
|
done < "$records"
|
|
373
376
|
fi
|
|
374
377
|
|
|
375
|
-
if ! "$JQ_BIN" -c --slurpfile hist "$historical" --arg scope "$SCOPE_CWD" --argjson limit "$LIMIT" --argjson archived "$INCLUDE_ARCHIVED" '
|
|
378
|
+
if ! "$JQ_BIN" -c --slurpfile hist "$historical" --argjson pins "$("${BASH_SOURCE[0]%/*}/sesh-pins.sh" read)" --arg scope "$SCOPE_CWD" --argjson limit "$LIMIT" --argjson archived "$INCLUDE_ARCHIVED" '
|
|
376
379
|
.meta as $meta
|
|
377
380
|
| ($hist | map({key: .sessionId, value: .}) | from_entries) as $histById
|
|
378
381
|
| [$meta[]
|
|
@@ -387,7 +390,7 @@ refresh() {
|
|
|
387
390
|
updatedEpoch: ($h.updatedEpoch // (($m.time_updated / 1000 | floor))),
|
|
388
391
|
title: (if ($h.title // "") != "" then $h.title else ($m.title // "(untitled)") end)}
|
|
389
392
|
| .searchText = ((.title | ascii_downcase) + " " + ($histById[.sessionId].fulltextLower // ""))]
|
|
390
|
-
|
|
393
|
+
| sort_by([(.cwd as $dir | $pins.directories | index($dir) != null), (.sessionId as $id | $pins.sessions | index($id) != null), .updatedEpoch]) | reverse
|
|
391
394
|
| (if $limit == 0 then . else .[:$limit] end)[]
|
|
392
395
|
' "$cache_state" > "$out"; then
|
|
393
396
|
set_status 'stale: snapshot construction failed; showing last good snapshot'
|
package/bin/sesh-pins.sh
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Persistent pins shared by the fzf picker and the opencode TUI plugin.
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
umask 077
|
|
5
|
+
|
|
6
|
+
PINS_FILE=${SESH_PINS_FILE:-"${XDG_DATA_HOME:-$HOME/.local/share}/sesh/pins.json"}
|
|
7
|
+
JQ_BIN=${SESH_JQ:-jq}
|
|
8
|
+
empty='{"sessions":[],"directories":[]}'
|
|
9
|
+
action=${1:-}
|
|
10
|
+
value=${2:-}
|
|
11
|
+
|
|
12
|
+
read_pins() {
|
|
13
|
+
if [ -f "$PINS_FILE" ]; then
|
|
14
|
+
local pins=''
|
|
15
|
+
pins=$("$JQ_BIN" -c 'select(type == "object") | {sessions: ((.sessions // []) | map(select(type == "string"))), directories: ((.directories // []) | map(select(type == "string")))}' "$PINS_FILE" 2>/dev/null) || pins=''
|
|
16
|
+
printf '%s\n' "${pins:-$empty}"
|
|
17
|
+
else
|
|
18
|
+
printf '%s\n' "$empty"
|
|
19
|
+
fi
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
case "$action" in
|
|
23
|
+
read) read_pins; exit 0 ;;
|
|
24
|
+
toggle-session)
|
|
25
|
+
[[ "$value" =~ ^ses_[A-Za-z0-9]+$ ]] || exit 2
|
|
26
|
+
field=sessions ;;
|
|
27
|
+
toggle-directory)
|
|
28
|
+
[[ "$value" == /* && "$value" != *$'\n'* ]] || exit 2
|
|
29
|
+
field=directories ;;
|
|
30
|
+
*) echo 'usage: sesh-pins.sh read|toggle-session ID|toggle-directory PATH' >&2; exit 2 ;;
|
|
31
|
+
esac
|
|
32
|
+
|
|
33
|
+
mkdir -p "$(dirname "$PINS_FILE")"
|
|
34
|
+
lock="$PINS_FILE.lock"
|
|
35
|
+
for ((attempt = 0; attempt < 50; attempt++)); do
|
|
36
|
+
if mkdir "$lock" 2>/dev/null; then printf '%s\n' "$$" > "$lock/pid"; break; fi
|
|
37
|
+
owner=''
|
|
38
|
+
[ ! -f "$lock/pid" ] || owner=$(cat "$lock/pid" 2>/dev/null || true)
|
|
39
|
+
if [[ "$owner" =~ ^[0-9]+$ ]] && ! kill -0 "$owner" 2>/dev/null; then
|
|
40
|
+
rm -f "$lock/pid" 2>/dev/null || true
|
|
41
|
+
rmdir "$lock" 2>/dev/null || true
|
|
42
|
+
fi
|
|
43
|
+
[ "$attempt" -lt 49 ] || { echo 'sesh: pins are busy; try again' >&2; exit 1; }
|
|
44
|
+
sleep 0.1
|
|
45
|
+
done
|
|
46
|
+
tmp=''
|
|
47
|
+
cleanup() { [ -z "$tmp" ] || rm -f "$tmp"; rm -f "$lock/pid"; rmdir "$lock" 2>/dev/null || true; }
|
|
48
|
+
trap cleanup EXIT
|
|
49
|
+
tmp=$(mktemp "${PINS_FILE}.XXXXXX")
|
|
50
|
+
read_pins | "$JQ_BIN" -c --arg field "$field" --arg value "$value" '
|
|
51
|
+
.[$field] |= (if index($value) then map(select(. != $value)) else . + [$value] end)
|
|
52
|
+
' > "$tmp"
|
|
53
|
+
mv -f "$tmp" "$PINS_FILE"
|
|
54
|
+
tmp=''
|
|
55
|
+
read_pins
|
package/bin/sesh-shortcuts.sh
CHANGED
|
@@ -7,7 +7,9 @@ sesh shortcuts
|
|
|
7
7
|
Enter Resume the selected session here
|
|
8
8
|
Ctrl-F Resume the selected session as a fork
|
|
9
9
|
Ctrl-G Toggle current-directory scope / all sessions
|
|
10
|
-
|
|
10
|
+
Ctrl-P Toggle transcript preview
|
|
11
|
+
Option-S Pin / unpin selected session
|
|
12
|
+
Option-D Pin / unpin selected directory
|
|
11
13
|
? Toggle this help
|
|
12
14
|
Ctrl-X Delete the selected session (asks to confirm)
|
|
13
15
|
Escape Exit the picker
|
package/bin/sesh.sh
CHANGED
|
@@ -90,6 +90,7 @@ q_list=$(shell_quote "$SCRIPT_DIR/sesh-list.sh")
|
|
|
90
90
|
q_worker=$(shell_quote "$SCRIPT_DIR/sesh-refresh-worker.sh")
|
|
91
91
|
q_preview=$(shell_quote "$SCRIPT_DIR/sesh-preview.sh")
|
|
92
92
|
q_delete=$(shell_quote "$SCRIPT_DIR/sesh-delete.sh")
|
|
93
|
+
q_pins=$(shell_quote "$SCRIPT_DIR/sesh-pins.sh")
|
|
93
94
|
q_shortcuts=$(shell_quote "$SCRIPT_DIR/sesh-shortcuts.sh")
|
|
94
95
|
q_state=$(shell_quote "$state_dir")
|
|
95
96
|
export SESH_LIST="$SCRIPT_DIR/sesh-list.sh"
|
|
@@ -119,6 +120,8 @@ preview_cmd="if [ -f $q_state/help ]; then $q_shortcuts; else $q_preview $q_stat
|
|
|
119
120
|
help_bind="?:transform:[ -z {q} ] && ( [ -f $q_state/help ] && rm -f $q_state/help && echo hide-preview || ( : > $q_state/help && echo show-preview+refresh-preview ) ) || echo 'put(?)'"
|
|
120
121
|
toggle_cmd="$q_list --toggle-scope --state-dir $q_state >/dev/null"
|
|
121
122
|
delete_cmd="$q_delete $q_state {2}"
|
|
123
|
+
pin_session_cmd="$q_pins toggle-session {2}"
|
|
124
|
+
pin_directory_cmd="$q_pins toggle-directory {5}"
|
|
122
125
|
|
|
123
126
|
query="$picker_query"
|
|
124
127
|
while :; do
|
|
@@ -128,10 +131,12 @@ while :; do
|
|
|
128
131
|
selection=$(FZF_DEFAULT_COMMAND='printf ""' FZF_DEFAULT_OPTS='' FZF_DEFAULT_OPTS_FILE='' "$SESH_FZF" --print-query --expect=ctrl-f --query="$query" --ansi --delimiter=$'\t' --with-nth=4 --track --id-nth=6 --disabled --layout=reverse --info=hidden --prompt='❯ ' \
|
|
129
132
|
--preview="$preview_cmd" --preview-window='right,50%,wrap,hidden' \
|
|
130
133
|
--header-first --header="$header_line" \
|
|
131
|
-
--bind='
|
|
134
|
+
--bind='ctrl-p:toggle-preview' \
|
|
132
135
|
--bind="$help_bind" \
|
|
133
136
|
--bind="ctrl-x:execute($delete_cmd)+transform-header($header_cmd)+reload($cache_cmd)" \
|
|
134
137
|
--bind="ctrl-g:execute($toggle_cmd)+transform-header($header_cmd)+reload($cache_cmd)" \
|
|
138
|
+
--bind="alt-s:execute-silent($pin_session_cmd)+reload($cache_cmd)" \
|
|
139
|
+
--bind="alt-d:execute-silent($pin_directory_cmd)+reload($cache_cmd)" \
|
|
135
140
|
--bind="change:reload:$cache_cmd" \
|
|
136
141
|
--bind="start,every(3):transform-header($header_cmd)+reload-sync:$cache_cmd")
|
|
137
142
|
status=$?
|
package/package.json
CHANGED
package/tui/sesh-panel.tsx
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
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 { readFile } from "node:fs/promises"
|
|
6
|
-
import {
|
|
5
|
+
import { mkdir, readFile, rename, rmdir, unlink, writeFile } from "node:fs/promises"
|
|
6
|
+
import { dirname, join } from "node:path"
|
|
7
|
+
import { getTreeSitterClient, RGBA, SyntaxStyle, TextAttributes } from "@opentui/core"
|
|
7
8
|
|
|
8
9
|
type ThemeColors = TuiPluginApi["theme"]["current"]
|
|
9
10
|
|
|
@@ -49,21 +50,24 @@ function buildMarkdownStyle(theme: ThemeColors): SyntaxStyle {
|
|
|
49
50
|
],
|
|
50
51
|
style: { foreground: theme.error },
|
|
51
52
|
},
|
|
52
|
-
|
|
53
|
+
// Some opencode themes give Markdown roles the same foreground as body
|
|
54
|
+
// text. Use their semantic accents here so transcript structure remains
|
|
55
|
+
// legible without recoloring the surrounding TUI or ordinary prose.
|
|
56
|
+
{ scope: ["markup.heading"], style: { foreground: theme.accent, bold: true } },
|
|
53
57
|
{
|
|
54
58
|
scope: ["markup.heading.1"],
|
|
55
|
-
style: { foreground: theme.
|
|
59
|
+
style: { foreground: theme.primary, bold: true, underline: true },
|
|
56
60
|
},
|
|
57
61
|
{
|
|
58
62
|
scope: ["markup.heading.2", "markup.heading.3", "markup.heading.4", "markup.heading.5", "markup.heading.6"],
|
|
59
|
-
style: { foreground: theme.
|
|
63
|
+
style: { foreground: theme.accent, bold: true },
|
|
60
64
|
},
|
|
61
|
-
{ scope: ["markup.bold", "markup.strong"], style: { foreground: theme.
|
|
62
|
-
{ scope: ["markup.italic"], style: { foreground: theme.
|
|
63
|
-
{ scope: ["markup.list"], style: { foreground: theme.
|
|
65
|
+
{ scope: ["markup.bold", "markup.strong"], style: { foreground: theme.accent, bold: true } },
|
|
66
|
+
{ scope: ["markup.italic"], style: { foreground: theme.syntaxString, italic: true } },
|
|
67
|
+
{ scope: ["markup.list"], style: { foreground: theme.syntaxKeyword } },
|
|
64
68
|
{ scope: ["markup.quote"], style: { foreground: theme.markdownBlockQuote, italic: true } },
|
|
65
|
-
{ scope: ["markup.raw", "markup.raw.block"], style: { foreground: theme.
|
|
66
|
-
{ scope: ["markup.raw.inline"], style: { foreground: theme.
|
|
69
|
+
{ scope: ["markup.raw", "markup.raw.block"], style: { foreground: theme.syntaxString } },
|
|
70
|
+
{ scope: ["markup.raw.inline"], style: { foreground: theme.syntaxString, background: theme.background } },
|
|
67
71
|
{
|
|
68
72
|
scope: ["markup.link", "markup.link.url", "string.special", "string.special.url"],
|
|
69
73
|
style: { foreground: theme.markdownLink, underline: true },
|
|
@@ -87,6 +91,77 @@ function getMarkdownStyle(theme: ThemeColors): SyntaxStyle | undefined {
|
|
|
87
91
|
}
|
|
88
92
|
}
|
|
89
93
|
|
|
94
|
+
type Pins = { sessions: string[]; directories: string[] }
|
|
95
|
+
const emptyPins = (): Pins => ({ sessions: [], directories: [] })
|
|
96
|
+
const pinsPath = () =>
|
|
97
|
+
process.env.SESH_PINS_FILE ?? join(process.env.XDG_DATA_HOME ?? join(process.env.HOME ?? "", ".local/share"), "sesh/pins.json")
|
|
98
|
+
|
|
99
|
+
async function readPins(): Promise<Pins> {
|
|
100
|
+
try {
|
|
101
|
+
const data = JSON.parse(await readFile(pinsPath(), "utf8")) as Partial<Pins>
|
|
102
|
+
return {
|
|
103
|
+
sessions: Array.isArray(data.sessions) ? data.sessions.filter((id): id is string => typeof id === "string") : [],
|
|
104
|
+
directories: Array.isArray(data.directories) ? data.directories.filter((dir): dir is string => typeof dir === "string") : [],
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
return emptyPins()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function togglePin(kind: keyof Pins, value: string): Promise<Pins> {
|
|
112
|
+
const file = pinsPath()
|
|
113
|
+
await mkdir(dirname(file), { recursive: true })
|
|
114
|
+
const lock = `${file}.lock`
|
|
115
|
+
let locked = false
|
|
116
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
117
|
+
try {
|
|
118
|
+
await mkdir(lock)
|
|
119
|
+
locked = true
|
|
120
|
+
break
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
|
|
123
|
+
try {
|
|
124
|
+
const owner = Number((await readFile(join(lock, "pid"), "utf8")).trim())
|
|
125
|
+
if (Number.isSafeInteger(owner) && owner > 0) {
|
|
126
|
+
try {
|
|
127
|
+
process.kill(owner, 0)
|
|
128
|
+
} catch (check) {
|
|
129
|
+
if ((check as NodeJS.ErrnoException).code === "ESRCH") {
|
|
130
|
+
await unlink(join(lock, "pid")).catch(() => {})
|
|
131
|
+
await rmdir(lock).catch(() => {})
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch {}
|
|
136
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!locked) throw new Error("pins are busy")
|
|
140
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}`
|
|
141
|
+
try {
|
|
142
|
+
await writeFile(join(lock, "pid"), `${process.pid}\n`)
|
|
143
|
+
const next = await readPins()
|
|
144
|
+
next[kind] = next[kind].includes(value) ? next[kind].filter((item) => item !== value) : [...next[kind], value]
|
|
145
|
+
await writeFile(tmp, JSON.stringify(next) + "\n", { mode: 0o600 })
|
|
146
|
+
await rename(tmp, file)
|
|
147
|
+
return next
|
|
148
|
+
} finally {
|
|
149
|
+
await unlink(tmp).catch(() => {})
|
|
150
|
+
await unlink(join(lock, "pid")).catch(() => {})
|
|
151
|
+
await rmdir(lock)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function pinnedFirst(entries: Entry[], pins: Pins): Entry[] {
|
|
156
|
+
const sessions = new Set(pins.sessions)
|
|
157
|
+
const directories = new Set(pins.directories)
|
|
158
|
+
return [...entries].sort((a, b) => {
|
|
159
|
+
const aRank = sessions.has(a.id) ? 2 : directories.has(a.dir) ? 1 : 0
|
|
160
|
+
const bRank = sessions.has(b.id) ? 2 : directories.has(b.dir) ? 1 : 0
|
|
161
|
+
return bRank - aRank || b.updated - a.updated
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
90
165
|
// Sessions panel: an opencode-native session switcher in two parts.
|
|
91
166
|
//
|
|
92
167
|
// 1. Sidebar (always visible): a compact recent-sessions section appended to
|
|
@@ -137,10 +212,9 @@ const SIDEBAR_TITLE_WIDTH = 27
|
|
|
137
212
|
const SIDEBAR_GROUP_WIDTH = 30
|
|
138
213
|
const HOME_TITLE_WIDTH = 30
|
|
139
214
|
|
|
140
|
-
// The sidebar
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
const SIDEBAR_LIMIT = 15
|
|
215
|
+
// The sidebar lists every session (newest first, pins ahead) and fills
|
|
216
|
+
// whatever vertical space it gets: overflow scrolls inside the stretched
|
|
217
|
+
// scrollbox. The picker (ctrl+o) remains the place for transcript search.
|
|
144
218
|
|
|
145
219
|
// Uppercase is included deliberately: a search box that silently drops shifted
|
|
146
220
|
// letters cannot be used for acronyms or paths like README.
|
|
@@ -269,7 +343,7 @@ function addTranscript(index: Map<string, string>, sid: string, data: string): v
|
|
|
269
343
|
async function buildSearchIndex(
|
|
270
344
|
api: TuiPluginApi,
|
|
271
345
|
entries: Entry[],
|
|
272
|
-
onProgress?: (progress: IndexProgress) => void,
|
|
346
|
+
onProgress?: (progress: IndexProgress, index: Map<string, string>) => void,
|
|
273
347
|
): Promise<Map<string, string>> {
|
|
274
348
|
const index = new Map<string, string>()
|
|
275
349
|
for (const entry of entries) {
|
|
@@ -279,7 +353,7 @@ async function buildSearchIndex(
|
|
|
279
353
|
const remaining = new Set(ids)
|
|
280
354
|
const total = ids.length
|
|
281
355
|
let indexed = 0
|
|
282
|
-
const report = (complete: boolean) => onProgress?.({ indexed, total, complete })
|
|
356
|
+
const report = (complete: boolean) => onProgress?.({ indexed, total, complete }, index)
|
|
283
357
|
report(false)
|
|
284
358
|
|
|
285
359
|
const db = await openTranscriptDb()
|
|
@@ -309,7 +383,7 @@ async function buildSearchIndex(
|
|
|
309
383
|
if (remaining.size > 0) {
|
|
310
384
|
await buildSearchIndexRemote(api, [...remaining], index, () => {
|
|
311
385
|
indexed += 1
|
|
312
|
-
report(false)
|
|
386
|
+
if (indexed % TRANSCRIPT_BATCH === 0) report(false)
|
|
313
387
|
})
|
|
314
388
|
}
|
|
315
389
|
report(true)
|
|
@@ -446,7 +520,9 @@ function createTranscriptPreview(api: TuiPluginApi) {
|
|
|
446
520
|
}
|
|
447
521
|
setPreviewID(entry.id)
|
|
448
522
|
setPreviewText("Loading…")
|
|
449
|
-
void fetchTranscriptText(api, entry.id).then(
|
|
523
|
+
void fetchTranscriptText(api, entry.id).then((text) => {
|
|
524
|
+
if (previewID() === entry.id) setPreviewText(text)
|
|
525
|
+
})
|
|
450
526
|
api.ui.dialog.replace(
|
|
451
527
|
() => (
|
|
452
528
|
<box flexDirection="column" paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
|
|
@@ -464,7 +540,11 @@ function createTranscriptPreview(api: TuiPluginApi) {
|
|
|
464
540
|
maxHeight={Math.max(6, Math.floor(api.renderer.height / 2))}
|
|
465
541
|
>
|
|
466
542
|
{getMarkdownStyle(api.theme.current) ? (
|
|
467
|
-
<markdown
|
|
543
|
+
<markdown
|
|
544
|
+
content={previewText()}
|
|
545
|
+
syntaxStyle={getMarkdownStyle(api.theme.current)!}
|
|
546
|
+
treeSitterClient={getTreeSitterClient()}
|
|
547
|
+
/>
|
|
468
548
|
) : (
|
|
469
549
|
<text wrapMode="word">{previewText()}</text>
|
|
470
550
|
)}
|
|
@@ -500,10 +580,13 @@ type TreeRow =
|
|
|
500
580
|
| { kind: "group"; dir: string; label: string; count: number }
|
|
501
581
|
| { kind: "item"; entry: Entry; last: boolean }
|
|
502
582
|
|
|
503
|
-
|
|
583
|
+
type PinProps = { pins: () => Pins; onTogglePin: (kind: keyof Pins, value: string) => void; refreshPins: () => void }
|
|
584
|
+
|
|
585
|
+
function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
504
586
|
const theme = () => props.api.theme.current
|
|
505
587
|
const home = process.env.HOME ?? ""
|
|
506
588
|
const [entries, setEntries] = createSignal<Entry[]>([])
|
|
589
|
+
const [sectionCollapsed, setSectionCollapsed] = createSignal(false)
|
|
507
590
|
const [collapsed, setCollapsed] = createSignal<Record<string, boolean>>({})
|
|
508
591
|
const [hovered, setHovered] = createSignal<string>()
|
|
509
592
|
const preview = createTranscriptPreview(props.api)
|
|
@@ -525,6 +608,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
525
608
|
onMount(() => {
|
|
526
609
|
let alive = true
|
|
527
610
|
const load = async () => {
|
|
611
|
+
props.refreshPins()
|
|
528
612
|
try {
|
|
529
613
|
const { entries: all } = await fetchEntries(props.api)
|
|
530
614
|
if (alive) {
|
|
@@ -547,13 +631,14 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
547
631
|
|
|
548
632
|
const filteredEntries = createMemo(() => {
|
|
549
633
|
const q = query().trim().toLowerCase()
|
|
550
|
-
|
|
551
|
-
|
|
634
|
+
const ordered = pinnedFirst(entries(), props.pins())
|
|
635
|
+
if (!q) return ordered
|
|
636
|
+
return ordered.filter(
|
|
552
637
|
(entry) => entry.title.toLowerCase().includes(q) || entry.dir.toLowerCase().includes(q),
|
|
553
638
|
)
|
|
554
639
|
})
|
|
555
640
|
|
|
556
|
-
const shownEntries = createMemo(() => filteredEntries()
|
|
641
|
+
const shownEntries = createMemo(() => filteredEntries())
|
|
557
642
|
const remaining = createMemo(() => Math.max(0, filteredEntries().length - shownEntries().length))
|
|
558
643
|
|
|
559
644
|
const tree = createMemo<TreeRow[]>(() => {
|
|
@@ -565,7 +650,12 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
565
650
|
}
|
|
566
651
|
const ordered = [...groups.entries()]
|
|
567
652
|
.map(([dir, list]) => ({ dir, list, updated: Math.max(...list.map((entry) => entry.updated)) }))
|
|
568
|
-
.sort((a, b) =>
|
|
653
|
+
.sort((a, b) => {
|
|
654
|
+
const pins = props.pins()
|
|
655
|
+
const rank = (group: { dir: string; list: Entry[] }) =>
|
|
656
|
+
pins.directories.includes(group.dir) ? 2 : group.list.some((entry) => pins.sessions.includes(entry.id)) ? 1 : 0
|
|
657
|
+
return rank(b) - rank(a) || b.updated - a.updated
|
|
658
|
+
})
|
|
569
659
|
const rows: TreeRow[] = []
|
|
570
660
|
for (const group of ordered) {
|
|
571
661
|
rows.push({ kind: "group", dir: group.dir, label: prettyDir(group.dir, home), count: group.list.length })
|
|
@@ -581,6 +671,16 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
581
671
|
setCollapsed((prev) => ({ ...prev, [dir]: !prev[dir] }))
|
|
582
672
|
}
|
|
583
673
|
|
|
674
|
+
const toggleSection = () => {
|
|
675
|
+
if (!sectionCollapsed()) {
|
|
676
|
+
cancelDelete()
|
|
677
|
+
setNavActive(false)
|
|
678
|
+
setSearching(false)
|
|
679
|
+
setHovered(undefined)
|
|
680
|
+
}
|
|
681
|
+
setSectionCollapsed((value) => !value)
|
|
682
|
+
}
|
|
683
|
+
|
|
584
684
|
// Flat order of the rows a cursor can land on, so keyboard navigation and
|
|
585
685
|
// mouse hover resolve to the same row.
|
|
586
686
|
const itemRows = createMemo(() => tree().flatMap((row) => (row.kind === "item" ? [row.entry] : [])))
|
|
@@ -591,9 +691,14 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
591
691
|
if (count === 0) return
|
|
592
692
|
setCursor((value) => Math.max(0, Math.min(count - 1, value + delta)))
|
|
593
693
|
}
|
|
694
|
+
let previousRows: Entry[] = []
|
|
594
695
|
createEffect(() => {
|
|
595
|
-
const
|
|
596
|
-
|
|
696
|
+
const rows = itemRows()
|
|
697
|
+
const selected = previousRows[cursor()]?.id
|
|
698
|
+
const position = rows.findIndex((entry) => entry.id === selected)
|
|
699
|
+
if (position >= 0 && position !== cursor()) setCursor(position)
|
|
700
|
+
else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1))
|
|
701
|
+
previousRows = rows
|
|
597
702
|
})
|
|
598
703
|
|
|
599
704
|
const sessionStatus = (id: string): "running" | "waiting" | "idle" => {
|
|
@@ -621,8 +726,9 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
621
726
|
}
|
|
622
727
|
|
|
623
728
|
// Deleting is irreversible, so ctrl+x arms the row and a second ctrl+x (or y)
|
|
624
|
-
// commits it.
|
|
625
|
-
//
|
|
729
|
+
// commits it. The arm survives pointer movement — edge-hover flicker must
|
|
730
|
+
// not eat the confirm — and is cleared by the five-second timeout, n, Esc,
|
|
731
|
+
// collapsing the section, or the delete itself.
|
|
626
732
|
const requestDelete = (entry: Entry) => {
|
|
627
733
|
if (deleting()) return
|
|
628
734
|
if (pendingDelete() === entry.id) {
|
|
@@ -634,6 +740,18 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
634
740
|
confirmTimer = setTimeout(() => setPendingDelete(undefined), 5000)
|
|
635
741
|
}
|
|
636
742
|
|
|
743
|
+
// The confirm always targets the visibly armed row ("ctrl+x again"),
|
|
744
|
+
// wherever the pointer or cursor currently is. Returns true when an arm
|
|
745
|
+
// was pending (and is now committed or discarded as stale).
|
|
746
|
+
const confirmArmed = (): boolean => {
|
|
747
|
+
const id = pendingDelete()
|
|
748
|
+
if (!id) return false
|
|
749
|
+
const entry = entries().find((e) => e.id === id)
|
|
750
|
+
if (!entry) cancelDelete()
|
|
751
|
+
else void deleteEntry(entry)
|
|
752
|
+
return true
|
|
753
|
+
}
|
|
754
|
+
|
|
637
755
|
const deleteEntry = async (entry: Entry) => {
|
|
638
756
|
cancelDelete()
|
|
639
757
|
if (deleting()) return
|
|
@@ -710,7 +828,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
710
828
|
priority: 20,
|
|
711
829
|
bindings: [
|
|
712
830
|
{
|
|
713
|
-
key: "
|
|
831
|
+
key: "ctrl+p",
|
|
714
832
|
desc: "Preview session transcript",
|
|
715
833
|
preventDefault: true,
|
|
716
834
|
cmd: () => openSidebarPreview(entry),
|
|
@@ -719,8 +837,12 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
719
837
|
key: "ctrl+x",
|
|
720
838
|
desc: "Delete session",
|
|
721
839
|
preventDefault: true,
|
|
722
|
-
cmd: () =>
|
|
840
|
+
cmd: () => {
|
|
841
|
+
if (!confirmArmed()) requestDelete(entry)
|
|
842
|
+
},
|
|
723
843
|
},
|
|
844
|
+
{ key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
|
|
845
|
+
{ key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
|
|
724
846
|
{
|
|
725
847
|
key: "/",
|
|
726
848
|
desc: "Search sessions",
|
|
@@ -731,13 +853,6 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
731
853
|
})
|
|
732
854
|
})
|
|
733
855
|
|
|
734
|
-
// Mouse hover must not commit a keyboard-initiated confirm (and vice versa):
|
|
735
|
-
// leaving the armed row abandons the pending delete.
|
|
736
|
-
createEffect(() => {
|
|
737
|
-
const pending = pendingDelete()
|
|
738
|
-
if (pending && !navActive() && pending !== hovered()) cancelDelete()
|
|
739
|
-
})
|
|
740
|
-
|
|
741
856
|
// Keyboard navigation is opt-in (click the "Sessions" heading or the search
|
|
742
857
|
// box) so the sidebar never steals the arrow keys from the main prompt.
|
|
743
858
|
createEffect(() => {
|
|
@@ -757,7 +872,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
757
872
|
cmd: () => setCursor(Math.max(0, itemRows().length - 1)),
|
|
758
873
|
},
|
|
759
874
|
{
|
|
760
|
-
key: "
|
|
875
|
+
key: "ctrl+p",
|
|
761
876
|
desc: "Preview session transcript",
|
|
762
877
|
preventDefault: true,
|
|
763
878
|
cmd: () => {
|
|
@@ -770,10 +885,29 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
770
885
|
desc: "Delete session",
|
|
771
886
|
preventDefault: true,
|
|
772
887
|
cmd: () => {
|
|
888
|
+
if (confirmArmed()) return
|
|
773
889
|
const entry = itemRows()[cursor()]
|
|
774
890
|
if (entry) requestDelete(entry)
|
|
775
891
|
},
|
|
776
892
|
},
|
|
893
|
+
{
|
|
894
|
+
key: "option+s",
|
|
895
|
+
desc: "Pin session",
|
|
896
|
+
preventDefault: true,
|
|
897
|
+
cmd: () => {
|
|
898
|
+
const entry = itemRows()[cursor()]
|
|
899
|
+
if (entry) props.onTogglePin("sessions", entry.id)
|
|
900
|
+
},
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
key: "option+d",
|
|
904
|
+
desc: "Pin directory",
|
|
905
|
+
preventDefault: true,
|
|
906
|
+
cmd: () => {
|
|
907
|
+
const entry = itemRows()[cursor()]
|
|
908
|
+
if (entry) props.onTogglePin("directories", entry.dir)
|
|
909
|
+
},
|
|
910
|
+
},
|
|
777
911
|
{
|
|
778
912
|
key: "enter",
|
|
779
913
|
desc: "Open session",
|
|
@@ -799,8 +933,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
799
933
|
desc: "Confirm delete",
|
|
800
934
|
preventDefault: true,
|
|
801
935
|
cmd: () => {
|
|
802
|
-
|
|
803
|
-
if (entry && pendingDelete() === entry.id) void deleteEntry(entry)
|
|
936
|
+
confirmArmed()
|
|
804
937
|
},
|
|
805
938
|
},
|
|
806
939
|
{ key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
|
|
@@ -818,16 +951,22 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
818
951
|
})
|
|
819
952
|
|
|
820
953
|
return (
|
|
821
|
-
<box flexDirection="column" paddingRight={1}>
|
|
822
|
-
<box flexDirection="row" gap={1}
|
|
823
|
-
<text
|
|
954
|
+
<box flexDirection="column" paddingRight={1} flexGrow={1} flexShrink={1}>
|
|
955
|
+
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
|
956
|
+
<text flexShrink={1} onMouseDown={() => {
|
|
957
|
+
if (!sectionCollapsed()) setNavActive((value) => !value)
|
|
958
|
+
}}>
|
|
824
959
|
<b>Sessions</b>
|
|
825
960
|
<span style={{ fg: theme().textMuted }}>
|
|
826
961
|
{query().trim() ? ` (${filteredEntries().length}/${entries().length})` : ` (${entries().length})`}
|
|
827
962
|
</span>
|
|
828
|
-
<span style={{ fg:
|
|
963
|
+
<span style={{ fg: theme().accent }}>{navActive() ? " · nav" : ""}</span>
|
|
964
|
+
</text>
|
|
965
|
+
<text flexShrink={0} style={{ fg: theme().textMuted }} onMouseDown={toggleSection}>
|
|
966
|
+
{sectionCollapsed() ? "▸ show" : "▾ hide"}
|
|
829
967
|
</text>
|
|
830
968
|
</box>
|
|
969
|
+
<Show when={!sectionCollapsed()}>
|
|
831
970
|
<Show
|
|
832
971
|
when={entries().length > 0}
|
|
833
972
|
fallback={
|
|
@@ -864,6 +1003,8 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
864
1003
|
</Show>
|
|
865
1004
|
</box>
|
|
866
1005
|
<scrollbox
|
|
1006
|
+
flexGrow={1}
|
|
1007
|
+
flexShrink={1}
|
|
867
1008
|
verticalScrollbarOptions={{ visible: false }}
|
|
868
1009
|
horizontalScrollbarOptions={{ visible: false }}
|
|
869
1010
|
>
|
|
@@ -873,7 +1014,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
873
1014
|
<box flexDirection="row" paddingTop={1} onMouseDown={() => toggleGroup(row.dir)}>
|
|
874
1015
|
<text flexGrow={1} flexShrink={1} wrapMode="none">
|
|
875
1016
|
<span style={{ fg: theme().textMuted }}>{collapsed()[row.dir] ? "▸ " : "▾ "}</span>
|
|
876
|
-
<b>{truncate(row.label, SIDEBAR_GROUP_WIDTH)}</b>
|
|
1017
|
+
<b>{props.pins().directories.includes(row.dir) ? "★ " : ""}{truncate(row.label, SIDEBAR_GROUP_WIDTH - (props.pins().directories.includes(row.dir) ? 2 : 0))}</b>
|
|
877
1018
|
</text>
|
|
878
1019
|
<text flexShrink={0} style={{ fg: theme().textMuted }}>
|
|
879
1020
|
{" "}({row.count})
|
|
@@ -890,7 +1031,16 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
890
1031
|
? theme().backgroundElement
|
|
891
1032
|
: RGBA.fromInts(0, 0, 0, 0)
|
|
892
1033
|
}
|
|
893
|
-
onMouseOver={() =>
|
|
1034
|
+
onMouseOver={() => {
|
|
1035
|
+
setHovered(row.entry.id)
|
|
1036
|
+
// The keyboard layer wins over the hover layer while nav is
|
|
1037
|
+
// active. Keep its cursor on the hovered row so Option-S/Option-D
|
|
1038
|
+
// cannot silently pin a different (often current) session.
|
|
1039
|
+
if (navActive()) {
|
|
1040
|
+
const index = itemRows().findIndex((entry) => entry.id === row.entry.id)
|
|
1041
|
+
if (index >= 0) setCursor(index)
|
|
1042
|
+
}
|
|
1043
|
+
}}
|
|
894
1044
|
onMouseOut={() => setHovered(undefined)}
|
|
895
1045
|
onMouseDown={() => props.api.route.navigate("session", { sessionID: row.entry.id })}
|
|
896
1046
|
>
|
|
@@ -909,7 +1059,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
909
1059
|
</span>
|
|
910
1060
|
</text>
|
|
911
1061
|
<Highlighted
|
|
912
|
-
text={truncate(row.entry.title, SIDEBAR_TITLE_WIDTH)}
|
|
1062
|
+
text={`${props.pins().sessions.includes(row.entry.id) ? "★ " : ""}${truncate(row.entry.title, SIDEBAR_TITLE_WIDTH - (props.pins().sessions.includes(row.entry.id) ? 2 : 0))}`}
|
|
913
1063
|
query={query()}
|
|
914
1064
|
color={isPendingDelete(row.entry.id) ? theme().error : theme().text}
|
|
915
1065
|
matchColor={theme().warning}
|
|
@@ -925,19 +1075,23 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
925
1075
|
<Show when={remaining() > 0}>
|
|
926
1076
|
<text style={{ fg: theme().textMuted }}>{`… ${remaining()} more · ctrl+o for all`}</text>
|
|
927
1077
|
</Show>
|
|
1078
|
+
<Show when={query().trim() && filteredEntries().length === 0}>
|
|
1079
|
+
<text style={{ fg: theme().textMuted }}>No title or directory matches · ctrl+o to search transcripts</text>
|
|
1080
|
+
</Show>
|
|
928
1081
|
<Show when={navActive()}>
|
|
929
1082
|
<box paddingTop={1}>
|
|
930
|
-
<text style={{ fg: theme().textMuted }}>↑↓ move · enter open ·
|
|
1083
|
+
<text style={{ fg: theme().textMuted }}>↑↓ move · enter open · ctrl+p preview · option+s/d pin · ctrl+x delete · esc done</text>
|
|
931
1084
|
</box>
|
|
932
1085
|
</Show>
|
|
933
1086
|
</Show>
|
|
1087
|
+
</Show>
|
|
934
1088
|
</box>
|
|
935
1089
|
)
|
|
936
1090
|
}
|
|
937
1091
|
|
|
938
1092
|
const HOME_SESSION_LIMIT = 6
|
|
939
1093
|
|
|
940
|
-
function HomeSessions(props: { api: TuiPluginApi }) {
|
|
1094
|
+
function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
941
1095
|
const theme = () => props.api.theme.current
|
|
942
1096
|
const home = process.env.HOME ?? ""
|
|
943
1097
|
const [entries, setEntries] = createSignal<Entry[]>([])
|
|
@@ -992,11 +1146,13 @@ function HomeSessions(props: { api: TuiPluginApi }) {
|
|
|
992
1146
|
priority: 20,
|
|
993
1147
|
bindings: [
|
|
994
1148
|
{
|
|
995
|
-
key: "
|
|
1149
|
+
key: "ctrl+p",
|
|
996
1150
|
desc: "Preview session transcript",
|
|
997
1151
|
preventDefault: true,
|
|
998
1152
|
cmd: () => preview.open(entry),
|
|
999
1153
|
},
|
|
1154
|
+
{ key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
|
|
1155
|
+
{ key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
|
|
1000
1156
|
],
|
|
1001
1157
|
})
|
|
1002
1158
|
})
|
|
@@ -1007,6 +1163,7 @@ function HomeSessions(props: { api: TuiPluginApi }) {
|
|
|
1007
1163
|
onMount(() => {
|
|
1008
1164
|
let alive = true
|
|
1009
1165
|
const load = async () => {
|
|
1166
|
+
props.refreshPins()
|
|
1010
1167
|
try {
|
|
1011
1168
|
const { entries: all } = await fetchEntries(props.api)
|
|
1012
1169
|
if (alive) {
|
|
@@ -1032,7 +1189,7 @@ function HomeSessions(props: { api: TuiPluginApi }) {
|
|
|
1032
1189
|
(entry) => entry.title.toLowerCase().includes(q) || entry.dir.toLowerCase().includes(q),
|
|
1033
1190
|
)
|
|
1034
1191
|
: entries()
|
|
1035
|
-
return list.slice(0, HOME_SESSION_LIMIT)
|
|
1192
|
+
return pinnedFirst(list, props.pins()).slice(0, HOME_SESSION_LIMIT)
|
|
1036
1193
|
})
|
|
1037
1194
|
|
|
1038
1195
|
return (
|
|
@@ -1097,19 +1254,19 @@ function HomeSessions(props: { api: TuiPluginApi }) {
|
|
|
1097
1254
|
○
|
|
1098
1255
|
</text>
|
|
1099
1256
|
<Highlighted
|
|
1100
|
-
text={truncate(entry.title, HOME_TITLE_WIDTH)}
|
|
1257
|
+
text={`${props.pins().sessions.includes(entry.id) ? "★ " : ""}${truncate(entry.title, HOME_TITLE_WIDTH - (props.pins().sessions.includes(entry.id) ? 2 : 0))}`}
|
|
1101
1258
|
query={query()}
|
|
1102
1259
|
color={theme().text}
|
|
1103
1260
|
matchColor={theme().warning}
|
|
1104
1261
|
/>
|
|
1105
1262
|
<text flexShrink={0} style={{ fg: theme().textMuted }}>
|
|
1106
|
-
{prettyDir(entry.dir, home)} · {ago(entry.updated)}
|
|
1263
|
+
{props.pins().directories.includes(entry.dir) ? "★ " : ""}{prettyDir(entry.dir, home)} · {ago(entry.updated)}
|
|
1107
1264
|
</text>
|
|
1108
1265
|
</box>
|
|
1109
1266
|
)}
|
|
1110
1267
|
</For>
|
|
1111
1268
|
<Show when={visible().length === 0}>
|
|
1112
|
-
<text style={{ fg: theme().textMuted }}>No
|
|
1269
|
+
<text style={{ fg: theme().textMuted }}>No title or directory matches · ctrl+o to search transcripts</text>
|
|
1113
1270
|
</Show>
|
|
1114
1271
|
</Show>
|
|
1115
1272
|
</box>
|
|
@@ -1117,19 +1274,40 @@ function HomeSessions(props: { api: TuiPluginApi }) {
|
|
|
1117
1274
|
}
|
|
1118
1275
|
|
|
1119
1276
|
const tui: TuiPlugin = async (api) => {
|
|
1277
|
+
const [pins, setPins] = createSignal<Pins>(emptyPins())
|
|
1278
|
+
let pinVersion = 0
|
|
1279
|
+
const refreshPins = () => {
|
|
1280
|
+
const version = pinVersion
|
|
1281
|
+
void readPins().then((latest) => {
|
|
1282
|
+
if (version === pinVersion) setPins(latest)
|
|
1283
|
+
})
|
|
1284
|
+
}
|
|
1285
|
+
refreshPins()
|
|
1286
|
+
const onTogglePin = (kind: keyof Pins, value: string) => {
|
|
1287
|
+
if (!value) return
|
|
1288
|
+
pinVersion += 1
|
|
1289
|
+
void togglePin(kind, value).then(
|
|
1290
|
+
(updated) => {
|
|
1291
|
+
setPins(updated)
|
|
1292
|
+
api.ui.toast({ message: `${updated[kind].includes(value) ? "Pinned" : "Unpinned"} ${kind === "sessions" ? "session" : "directory"}`, variant: "info" })
|
|
1293
|
+
},
|
|
1294
|
+
() => api.ui.toast({ message: "Could not update pins", variant: "error" }),
|
|
1295
|
+
)
|
|
1296
|
+
}
|
|
1120
1297
|
api.slots.register({
|
|
1121
1298
|
order: 100,
|
|
1122
1299
|
slots: {
|
|
1123
1300
|
sidebar_content() {
|
|
1124
|
-
return <SidebarSessions api={api} />
|
|
1301
|
+
return <SidebarSessions api={api} pins={pins} onTogglePin={onTogglePin} refreshPins={refreshPins} />
|
|
1125
1302
|
},
|
|
1126
1303
|
home_bottom() {
|
|
1127
|
-
return <HomeSessions api={api} />
|
|
1304
|
+
return <HomeSessions api={api} pins={pins} onTogglePin={onTogglePin} refreshPins={refreshPins} />
|
|
1128
1305
|
},
|
|
1129
1306
|
},
|
|
1130
1307
|
})
|
|
1131
1308
|
|
|
1132
1309
|
const openPicker = async () => {
|
|
1310
|
+
refreshPins()
|
|
1133
1311
|
if (api.mode.current() !== BASE_MODE) return
|
|
1134
1312
|
if (api.renderer.currentFocusedEditor === null) return
|
|
1135
1313
|
|
|
@@ -1159,16 +1337,20 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1159
1337
|
total: 0,
|
|
1160
1338
|
complete: false,
|
|
1161
1339
|
})
|
|
1162
|
-
void buildSearchIndex(api, entries,
|
|
1163
|
-
|
|
1164
|
-
|
|
1340
|
+
void buildSearchIndex(api, entries, (progress, index) => {
|
|
1341
|
+
setIndexProgress(progress)
|
|
1342
|
+
// Publish a fresh snapshot: the indexing workers mutate their own map,
|
|
1343
|
+
// while Solid needs a new reference to update search results mid-scan.
|
|
1344
|
+
setSearchIndex(new Map(index))
|
|
1345
|
+
})
|
|
1165
1346
|
type DialogRow = { kind: "group"; label: string; count: number } | { kind: "item"; entry: Entry }
|
|
1166
1347
|
|
|
1348
|
+
const orderedEntries = createMemo(() => pinnedFirst(allEntries(), pins()))
|
|
1167
1349
|
const matchedGroups = createMemo(() => {
|
|
1168
1350
|
const q = query().trim().toLowerCase()
|
|
1169
1351
|
const index = searchIndex()
|
|
1170
1352
|
const group = scope()
|
|
1171
|
-
const matched =
|
|
1353
|
+
const matched = orderedEntries().filter((entry) => {
|
|
1172
1354
|
if (group && entry.group !== group) return false
|
|
1173
1355
|
if (!q) return true
|
|
1174
1356
|
const haystack = index.get(entry.id) ?? `${entry.title} ${entry.dir}`.toLowerCase()
|
|
@@ -1180,7 +1362,14 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1180
1362
|
list.push(entry)
|
|
1181
1363
|
byGroup.set(entry.group, list)
|
|
1182
1364
|
}
|
|
1183
|
-
|
|
1365
|
+
const groups = [...byGroup.entries()].map(([name, list]) => ({ name, list }))
|
|
1366
|
+
const currentPins = pins()
|
|
1367
|
+
const rank = (group: (typeof groups)[number]) =>
|
|
1368
|
+
group.list.some((entry) => currentPins.directories.includes(entry.dir)) ? 2
|
|
1369
|
+
: group.list.some((entry) => currentPins.sessions.includes(entry.id)) ? 1 : 0
|
|
1370
|
+
return groups.sort((a, b) =>
|
|
1371
|
+
rank(b) - rank(a) || Math.max(...b.list.map((entry) => entry.updated)) - Math.max(...a.list.map((entry) => entry.updated)),
|
|
1372
|
+
)
|
|
1184
1373
|
})
|
|
1185
1374
|
|
|
1186
1375
|
const selectableEntries = createMemo(() => matchedGroups().flatMap((g) => g.list))
|
|
@@ -1202,9 +1391,14 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1202
1391
|
return bits.join(" · ")
|
|
1203
1392
|
})
|
|
1204
1393
|
|
|
1394
|
+
let previousSelection: Entry[] = []
|
|
1205
1395
|
createEffect(() => {
|
|
1206
|
-
const
|
|
1207
|
-
|
|
1396
|
+
const rows = selectableEntries()
|
|
1397
|
+
const selected = previousSelection[cursor()]?.id
|
|
1398
|
+
const position = rows.findIndex((entry) => entry.id === selected)
|
|
1399
|
+
if (position >= 0 && position !== cursor()) setCursor(position)
|
|
1400
|
+
else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1))
|
|
1401
|
+
previousSelection = rows
|
|
1208
1402
|
})
|
|
1209
1403
|
|
|
1210
1404
|
const pickerRows = createMemo<DialogRow[]>(() => {
|
|
@@ -1273,8 +1467,8 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1273
1467
|
setPreviewText(cached)
|
|
1274
1468
|
return
|
|
1275
1469
|
}
|
|
1276
|
-
//
|
|
1277
|
-
|
|
1470
|
+
// Never show another session's transcript beneath the new selection.
|
|
1471
|
+
setPreviewText("Loading…")
|
|
1278
1472
|
previewTimer = setTimeout(() => void loadPreview(id), 120)
|
|
1279
1473
|
})
|
|
1280
1474
|
|
|
@@ -1375,6 +1569,15 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1375
1569
|
cmd: () => setCursor(selectableEntries().length - 1),
|
|
1376
1570
|
},
|
|
1377
1571
|
{ key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
|
|
1572
|
+
{ key: "ctrl+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
|
|
1573
|
+
{ key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => {
|
|
1574
|
+
const entry = selectableEntries()[cursor()]
|
|
1575
|
+
if (entry) onTogglePin("sessions", entry.id)
|
|
1576
|
+
} },
|
|
1577
|
+
{ key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => {
|
|
1578
|
+
const entry = selectableEntries()[cursor()]
|
|
1579
|
+
if (entry) onTogglePin("directories", entry.dir)
|
|
1580
|
+
} },
|
|
1378
1581
|
{
|
|
1379
1582
|
key: "ctrl+x",
|
|
1380
1583
|
desc: "Delete session",
|
|
@@ -1425,16 +1628,6 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1425
1628
|
if (previewTimer) clearTimeout(previewTimer)
|
|
1426
1629
|
}
|
|
1427
1630
|
|
|
1428
|
-
const onSearchInput = (value: string) => {
|
|
1429
|
-
if (query() === "" && value.trim() === "" && value.length > 0) {
|
|
1430
|
-
if (pickerInput && !pickerInput.isDestroyed) pickerInput.value = ""
|
|
1431
|
-
setQuery("")
|
|
1432
|
-
togglePreview()
|
|
1433
|
-
return
|
|
1434
|
-
}
|
|
1435
|
-
setQuery(value)
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
1631
|
api.ui.dialog.replace(
|
|
1439
1632
|
() => (
|
|
1440
1633
|
<box flexDirection="column" flexGrow={1} paddingBottom={1} gap={1}>
|
|
@@ -1449,7 +1642,6 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1449
1642
|
</box>
|
|
1450
1643
|
<box paddingTop={1}>
|
|
1451
1644
|
<input
|
|
1452
|
-
ref={(r) => (pickerInput = r)}
|
|
1453
1645
|
focused
|
|
1454
1646
|
placeholder="Search title, directory, transcript…"
|
|
1455
1647
|
value={query()}
|
|
@@ -1457,7 +1649,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1457
1649
|
focusedBackgroundColor={api.theme.current.backgroundPanel}
|
|
1458
1650
|
focusedTextColor={api.theme.current.text}
|
|
1459
1651
|
cursorColor={api.theme.current.primary}
|
|
1460
|
-
onInput={
|
|
1652
|
+
onInput={setQuery}
|
|
1461
1653
|
onSubmit={() => choose()}
|
|
1462
1654
|
onKeyDown={(event) => {
|
|
1463
1655
|
if (event.name === "escape") {
|
|
@@ -1486,7 +1678,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1486
1678
|
row.kind === "group" ? (
|
|
1487
1679
|
<box paddingTop={1} paddingLeft={4}>
|
|
1488
1680
|
<text style={{ fg: api.theme.current.accent }} attributes={TextAttributes.BOLD}>
|
|
1489
|
-
{row.label} <span style={{ fg: api.theme.current.textMuted }}>({row.count})</span>
|
|
1681
|
+
{matchedGroups().find((group) => group.name === row.label)?.list.some((entry) => pins().directories.includes(entry.dir)) ? "★ " : ""}{row.label} <span style={{ fg: api.theme.current.textMuted }}>({row.count})</span>
|
|
1490
1682
|
</text>
|
|
1491
1683
|
</box>
|
|
1492
1684
|
) : (
|
|
@@ -1516,7 +1708,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1516
1708
|
</text>
|
|
1517
1709
|
</Show>
|
|
1518
1710
|
<Highlighted
|
|
1519
|
-
text={truncate(row.entry.title, 61)}
|
|
1711
|
+
text={`${pins().sessions.includes(row.entry.id) ? "★ " : ""}${truncate(row.entry.title, pins().sessions.includes(row.entry.id) ? 59 : 61)}`}
|
|
1520
1712
|
query={query()}
|
|
1521
1713
|
bold={row.entry.id === selectableEntries()[cursor()]?.id}
|
|
1522
1714
|
color={
|
|
@@ -1543,7 +1735,12 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1543
1735
|
>
|
|
1544
1736
|
{pendingDelete()?.id === row.entry.id
|
|
1545
1737
|
? "press y to delete"
|
|
1546
|
-
: `${prettyDir(row.entry.dir, process.env.HOME ?? "")} · ${ago(row.entry.updated)}
|
|
1738
|
+
: `${prettyDir(row.entry.dir, process.env.HOME ?? "")} · ${ago(row.entry.updated)}${
|
|
1739
|
+
query().trim() &&
|
|
1740
|
+
!`${row.entry.title} ${row.entry.dir}`.toLowerCase().includes(query().trim().toLowerCase())
|
|
1741
|
+
? " · transcript match"
|
|
1742
|
+
: ""
|
|
1743
|
+
}`}
|
|
1547
1744
|
</text>
|
|
1548
1745
|
</box>
|
|
1549
1746
|
)
|
|
@@ -1552,7 +1749,11 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1552
1749
|
<Show when={selectableEntries().length === 0}>
|
|
1553
1750
|
<box paddingLeft={4} paddingRight={4}>
|
|
1554
1751
|
<text style={{ fg: api.theme.current.textMuted }}>
|
|
1555
|
-
{
|
|
1752
|
+
{!indexProgress().complete
|
|
1753
|
+
? "No matches yet · still searching transcripts…"
|
|
1754
|
+
: scope()
|
|
1755
|
+
? `No matches in ${scope()} · ctrl+g for all projects`
|
|
1756
|
+
: "No sessions match · try another search"}
|
|
1556
1757
|
</text>
|
|
1557
1758
|
</box>
|
|
1558
1759
|
</Show>
|
|
@@ -1560,20 +1761,23 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1560
1761
|
<Show when={showPreview()}>
|
|
1561
1762
|
<box flexShrink={0} flexDirection="column" paddingLeft={4} paddingRight={4} height={12}>
|
|
1562
1763
|
<text style={{ fg: api.theme.current.textMuted }} attributes={TextAttributes.BOLD}>
|
|
1563
|
-
Preview
|
|
1764
|
+
{previewID() ? `Preview · ${truncate(selectableEntries()[cursor()]!.title, 48)}` : "Preview"}
|
|
1564
1765
|
</text>
|
|
1565
1766
|
<scrollbox flexGrow={1} scrollbarOptions={{ visible: false }}>
|
|
1566
|
-
<Show
|
|
1567
|
-
when={previewText()}
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1767
|
+
<Show when={previewID()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Select a session to preview</text>}>
|
|
1768
|
+
<Show when={previewText()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Loading…</text>}>
|
|
1769
|
+
{(text) =>
|
|
1770
|
+
getMarkdownStyle(api.theme.current) ? (
|
|
1771
|
+
<markdown
|
|
1772
|
+
content={text()}
|
|
1773
|
+
syntaxStyle={getMarkdownStyle(api.theme.current)!}
|
|
1774
|
+
treeSitterClient={getTreeSitterClient()}
|
|
1775
|
+
/>
|
|
1776
|
+
) : (
|
|
1777
|
+
<text wrapMode="word">{text()}</text>
|
|
1778
|
+
)
|
|
1779
|
+
}
|
|
1780
|
+
</Show>
|
|
1577
1781
|
</Show>
|
|
1578
1782
|
</scrollbox>
|
|
1579
1783
|
</box>
|
|
@@ -1586,7 +1790,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1586
1790
|
>
|
|
1587
1791
|
{pendingDelete()
|
|
1588
1792
|
? `Delete "${truncate(pendingDelete()!.title, 40)}"? y confirm · n cancel`
|
|
1589
|
-
: "↑↓ navigate · enter open · ctrl+x delete · ctrl+f fork · ctrl+g project ·
|
|
1793
|
+
: "↑↓ navigate · enter open · option+s/d pin · ctrl+x delete · ctrl+f fork · ctrl+g project · ctrl+p preview · esc close"}
|
|
1590
1794
|
</text>
|
|
1591
1795
|
</box>
|
|
1592
1796
|
</box>
|