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 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 `SIDEBAR_LIMIT` unarchived sessions
129
- with the current one highlighted, polled every 15 s; space previews a row and
130
- ctrl+x deletes one (armed, then confirmed). Anything bulkier belongs to the
131
- picker.
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, and the section is capped at `SIDEBAR_LIMIT` (`… N more · ctrl+o for
172
- all` is the escape hatch) — it must stay a glanceable recent list.
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, and `n`/Esc/moving
175
- cancels. The `y`/`n` layer is registered *only while armed*, so it can never
176
- shadow the search box's typing.
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.0...HEAD
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
- | Space (empty query) | Toggle the transcript preview |
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
- | Space | Toggle the transcript preview |
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
- Space to preview, Ctrl-X to delete, Esc to leave). The sidebar shows the 15 most
158
- recent sessions — the full picker is one keystroke away.
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
- | group_by(.cwd)
184
- | map({cwd: .[0].cwd, updated: (map(.updatedEpoch) | max), items: (sort_by(.updatedEpoch) | reverse)})
185
- | sort_by(.updated) | reverse
186
- | .[] as $g
187
- | (["", "", "unknown", ($magenta + ($g.cwd | homepath | gsub("[\u0000-\u001f\u007f-\u009f]";" ")) + $reset), $g.cwd, ("hdr:" + $g.cwd)] | @tsv),
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
- | [$s.agentId, $s.sessionId, $s.liveState, ($branch + " " + $c + ($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)))
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
- | sort_by(.updatedEpoch) | reverse
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'
@@ -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
@@ -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
- Space Toggle transcript preview
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='space:transform:[ -z {q} ] && echo toggle-preview || echo "put( )"' \
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
6
6
  "license": "MIT",
@@ -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 { RGBA, SyntaxStyle, TextAttributes } from "@opentui/core"
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
- { scope: ["markup.heading"], style: { foreground: theme.markdownHeading, bold: true } },
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.markdownHeading, bold: true, underline: true },
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.markdownHeading, bold: true },
63
+ style: { foreground: theme.accent, bold: true },
60
64
  },
61
- { scope: ["markup.bold", "markup.strong"], style: { foreground: theme.markdownStrong, bold: true } },
62
- { scope: ["markup.italic"], style: { foreground: theme.markdownEmph, italic: true } },
63
- { scope: ["markup.list"], style: { foreground: theme.markdownListItem } },
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.markdownCode } },
66
- { scope: ["markup.raw.inline"], style: { foreground: theme.markdownCode, background: theme.background } },
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 is a glanceable recent list, not a second picker: cap it so the
141
- // section cannot grow into a scrolling wall (the docs promise "most recent"),
142
- // and point at the picker for everything else.
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(setPreviewText)
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 content={previewText()} syntaxStyle={getMarkdownStyle(api.theme.current)!} />
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
- function SidebarSessions(props: { api: TuiPluginApi }) {
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
- if (!q) return entries()
551
- return entries().filter(
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().slice(0, SIDEBAR_LIMIT))
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) => b.updated - a.updated)
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 count = itemRows().length
596
- if (cursor() >= count) setCursor(Math.max(0, count - 1))
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. Anything else — another row, five seconds, a keypress elsewhere
625
- // — lets it go. The old behaviour deleted whatever was hovered outright.
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: "space",
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: () => requestDelete(entry),
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: "space",
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
- const entry = itemRows()[cursor()]
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} onMouseDown={() => setNavActive((value) => !value)}>
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: navActive() ? theme().accent : theme().textMuted }}>{navActive() ? " ▸ nav" : " ▸"}</span>
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={() => setHovered(row.entry.id)}
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 · space preview · ctrl+x delete · esc done</text>
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: "space",
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 matching sessions</text>
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, setIndexProgress).then(setSearchIndex)
1163
- let pickerInput: { value: string; isDestroyed?: boolean } | undefined
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 = allEntries().filter((entry) => {
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
- return [...byGroup.entries()].map(([name, list]) => ({ name, list }))
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 count = selectableEntries().length
1207
- if (cursor() >= count) setCursor(Math.max(0, count - 1))
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
- // Keep the previous transcript visible while the next one loads so the
1277
- // preview does not blink away on every cursor move.
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={onSearchInput}
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
- {scope() ? `No sessions in ${scope()}` : "No sessions match this search"}
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
- fallback={<text style={{ fg: api.theme.current.textMuted }}>Loading…</text>}
1569
- >
1570
- {(text) =>
1571
- getMarkdownStyle(api.theme.current) ? (
1572
- <markdown content={text()} syntaxStyle={getMarkdownStyle(api.theme.current)!} />
1573
- ) : (
1574
- <text wrapMode="word">{text()}</text>
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 · space preview · esc close"}
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>