@vaclav-synacek/pi-coding-agent-termux 0.50.7-1 → 0.51.1-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +2 -0
  4. package/dist/cli/args.js.map +1 -1
  5. package/dist/cli/session-picker.d.ts.map +1 -1
  6. package/dist/cli/session-picker.js +3 -1
  7. package/dist/cli/session-picker.js.map +1 -1
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +9 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/core/agent-session.d.ts +5 -1
  12. package/dist/core/agent-session.d.ts.map +1 -1
  13. package/dist/core/agent-session.js +76 -15
  14. package/dist/core/agent-session.js.map +1 -1
  15. package/dist/core/extensions/index.d.ts +3 -3
  16. package/dist/core/extensions/index.d.ts.map +1 -1
  17. package/dist/core/extensions/index.js +1 -1
  18. package/dist/core/extensions/index.js.map +1 -1
  19. package/dist/core/extensions/runner.d.ts +19 -1
  20. package/dist/core/extensions/runner.d.ts.map +1 -1
  21. package/dist/core/extensions/runner.js +42 -0
  22. package/dist/core/extensions/runner.js.map +1 -1
  23. package/dist/core/extensions/types.d.ts +88 -6
  24. package/dist/core/extensions/types.d.ts.map +1 -1
  25. package/dist/core/extensions/types.js +4 -1
  26. package/dist/core/extensions/types.js.map +1 -1
  27. package/dist/core/extensions/wrapper.d.ts.map +1 -1
  28. package/dist/core/extensions/wrapper.js +1 -1
  29. package/dist/core/extensions/wrapper.js.map +1 -1
  30. package/dist/core/keybindings.d.ts +1 -1
  31. package/dist/core/keybindings.d.ts.map +1 -1
  32. package/dist/core/keybindings.js +8 -0
  33. package/dist/core/keybindings.js.map +1 -1
  34. package/dist/core/model-registry.d.ts.map +1 -1
  35. package/dist/core/model-registry.js +27 -18
  36. package/dist/core/model-registry.js.map +1 -1
  37. package/dist/core/package-manager.d.ts.map +1 -1
  38. package/dist/core/package-manager.js +11 -9
  39. package/dist/core/package-manager.js.map +1 -1
  40. package/dist/core/resource-loader.d.ts +24 -0
  41. package/dist/core/resource-loader.d.ts.map +1 -1
  42. package/dist/core/resource-loader.js +88 -19
  43. package/dist/core/resource-loader.js.map +1 -1
  44. package/dist/core/sdk.d.ts.map +1 -1
  45. package/dist/core/sdk.js +1 -0
  46. package/dist/core/sdk.js.map +1 -1
  47. package/dist/core/session-manager.d.ts +2 -0
  48. package/dist/core/session-manager.d.ts.map +1 -1
  49. package/dist/core/session-manager.js +2 -0
  50. package/dist/core/session-manager.js.map +1 -1
  51. package/dist/core/settings-manager.d.ts +5 -0
  52. package/dist/core/settings-manager.d.ts.map +1 -1
  53. package/dist/core/settings-manager.js +16 -0
  54. package/dist/core/settings-manager.js.map +1 -1
  55. package/dist/core/skills.d.ts.map +1 -1
  56. package/dist/core/skills.js +1 -0
  57. package/dist/core/skills.js.map +1 -1
  58. package/dist/core/tools/bash.d.ts +11 -0
  59. package/dist/core/tools/bash.d.ts.map +1 -1
  60. package/dist/core/tools/bash.js +18 -3
  61. package/dist/core/tools/bash.js.map +1 -1
  62. package/dist/core/tools/edit.d.ts +2 -0
  63. package/dist/core/tools/edit.d.ts.map +1 -1
  64. package/dist/core/tools/edit.js.map +1 -1
  65. package/dist/core/tools/find.d.ts +2 -0
  66. package/dist/core/tools/find.d.ts.map +1 -1
  67. package/dist/core/tools/find.js.map +1 -1
  68. package/dist/core/tools/grep.d.ts +2 -0
  69. package/dist/core/tools/grep.d.ts.map +1 -1
  70. package/dist/core/tools/grep.js.map +1 -1
  71. package/dist/core/tools/index.d.ts +7 -7
  72. package/dist/core/tools/index.d.ts.map +1 -1
  73. package/dist/core/tools/index.js +5 -5
  74. package/dist/core/tools/index.js.map +1 -1
  75. package/dist/core/tools/ls.d.ts +2 -0
  76. package/dist/core/tools/ls.d.ts.map +1 -1
  77. package/dist/core/tools/ls.js.map +1 -1
  78. package/dist/core/tools/read.d.ts +2 -0
  79. package/dist/core/tools/read.d.ts.map +1 -1
  80. package/dist/core/tools/read.js.map +1 -1
  81. package/dist/core/tools/write.d.ts +2 -0
  82. package/dist/core/tools/write.d.ts.map +1 -1
  83. package/dist/core/tools/write.js.map +1 -1
  84. package/dist/index.d.ts +3 -3
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +1 -1
  87. package/dist/index.js.map +1 -1
  88. package/dist/modes/interactive/components/custom-message.d.ts.map +1 -1
  89. package/dist/modes/interactive/components/custom-message.js +0 -7
  90. package/dist/modes/interactive/components/custom-message.js.map +1 -1
  91. package/dist/modes/interactive/components/daxnuts.d.ts.map +1 -1
  92. package/dist/modes/interactive/components/daxnuts.js +1 -1
  93. package/dist/modes/interactive/components/daxnuts.js.map +1 -1
  94. package/dist/modes/interactive/components/scoped-models-selector.d.ts.map +1 -1
  95. package/dist/modes/interactive/components/scoped-models-selector.js +4 -1
  96. package/dist/modes/interactive/components/scoped-models-selector.js.map +1 -1
  97. package/dist/modes/interactive/components/session-selector-search.d.ts +4 -2
  98. package/dist/modes/interactive/components/session-selector-search.d.ts.map +1 -1
  99. package/dist/modes/interactive/components/session-selector-search.js +13 -4
  100. package/dist/modes/interactive/components/session-selector-search.js.map +1 -1
  101. package/dist/modes/interactive/components/session-selector.d.ts +12 -2
  102. package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
  103. package/dist/modes/interactive/components/session-selector.js +188 -57
  104. package/dist/modes/interactive/components/session-selector.js.map +1 -1
  105. package/dist/modes/interactive/components/settings-selector.d.ts +2 -0
  106. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  107. package/dist/modes/interactive/components/settings-selector.js +12 -0
  108. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  109. package/dist/modes/interactive/components/tree-selector.d.ts +6 -0
  110. package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
  111. package/dist/modes/interactive/components/tree-selector.js +43 -16
  112. package/dist/modes/interactive/components/tree-selector.js.map +1 -1
  113. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  114. package/dist/modes/interactive/interactive-mode.js +24 -15
  115. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  116. package/dist/modes/print-mode.d.ts.map +1 -1
  117. package/dist/modes/print-mode.js +4 -0
  118. package/dist/modes/print-mode.js.map +1 -1
  119. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  120. package/dist/modes/rpc/rpc-mode.js +4 -0
  121. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  122. package/dist/utils/clipboard-image.d.ts.map +1 -1
  123. package/dist/utils/clipboard-image.js +52 -10
  124. package/dist/utils/clipboard-image.js.map +1 -1
  125. package/dist/utils/clipboard-native.d.ts +7 -0
  126. package/dist/utils/clipboard-native.d.ts.map +1 -0
  127. package/dist/utils/clipboard-native.js +14 -0
  128. package/dist/utils/clipboard-native.js.map +1 -0
  129. package/dist/utils/tools-manager.d.ts.map +1 -1
  130. package/dist/utils/tools-manager.js +14 -0
  131. package/dist/utils/tools-manager.js.map +1 -1
  132. package/docs/custom-provider.md +2 -1
  133. package/docs/extensions.md +57 -9
  134. package/docs/keybindings.md +9 -0
  135. package/docs/models.md +43 -14
  136. package/docs/rpc.md +188 -1
  137. package/docs/settings.md +5 -1
  138. package/docs/termux.md +127 -0
  139. package/examples/extensions/README.md +9 -0
  140. package/examples/extensions/antigravity-image-gen.ts +1 -1
  141. package/examples/extensions/bash-spawn-hook.ts +30 -0
  142. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  143. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  144. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  145. package/examples/extensions/custom-provider-qwen-cli/index.ts +345 -0
  146. package/examples/extensions/custom-provider-qwen-cli/package.json +16 -0
  147. package/examples/extensions/dynamic-resources/SKILL.md +8 -0
  148. package/examples/extensions/dynamic-resources/dynamic.json +79 -0
  149. package/examples/extensions/dynamic-resources/dynamic.md +5 -0
  150. package/examples/extensions/dynamic-resources/index.ts +15 -0
  151. package/examples/extensions/hello.ts +1 -1
  152. package/examples/extensions/question.ts +1 -1
  153. package/examples/extensions/questionnaire.ts +1 -1
  154. package/examples/extensions/rpc-demo.ts +124 -0
  155. package/examples/extensions/sandbox/index.ts +1 -1
  156. package/examples/extensions/shutdown-command.ts +2 -2
  157. package/examples/extensions/ssh.ts +4 -4
  158. package/examples/extensions/subagent/index.ts +1 -1
  159. package/examples/extensions/titlebar-spinner.ts +58 -0
  160. package/examples/extensions/todo.ts +1 -1
  161. package/examples/extensions/tool-override.ts +1 -1
  162. package/examples/extensions/truncated-tool.ts +1 -1
  163. package/examples/extensions/with-deps/package-lock.json +2 -2
  164. package/examples/extensions/with-deps/package.json +1 -1
  165. package/examples/rpc-extension-ui.ts +632 -0
  166. package/examples/sdk/06-extensions.ts +1 -1
  167. package/examples/sdk/12-full-control.ts +1 -0
  168. package/package.json +6 -5
@@ -3,10 +3,11 @@ import { existsSync } from "node:fs";
3
3
  import { unlink } from "node:fs/promises";
4
4
  import * as os from "node:os";
5
5
  import { Container, getEditorKeybindings, Input, matchesKey, Spacer, Text, truncateToWidth, visibleWidth, } from "@mariozechner/pi-tui";
6
+ import { KeybindingsManager } from "../../../core/keybindings.js";
6
7
  import { theme } from "../theme/theme.js";
7
8
  import { DynamicBorder } from "./dynamic-border.js";
8
- import { keyHint } from "./keybinding-hints.js";
9
- import { filterAndSortSessions } from "./session-selector-search.js";
9
+ import { appKey, appKeyHint, keyHint } from "./keybinding-hints.js";
10
+ import { filterAndSortSessions, hasSessionName } from "./session-selector-search.js";
10
11
  function shortenPath(path) {
11
12
  const home = os.homedir();
12
13
  if (!path)
@@ -23,20 +24,24 @@ function formatSessionDate(date) {
23
24
  const diffHours = Math.floor(diffMs / 3600000);
24
25
  const diffDays = Math.floor(diffMs / 86400000);
25
26
  if (diffMins < 1)
26
- return "just now";
27
+ return "now";
27
28
  if (diffMins < 60)
28
- return `${diffMins} minute${diffMins !== 1 ? "s" : ""} ago`;
29
+ return `${diffMins}m`;
29
30
  if (diffHours < 24)
30
- return `${diffHours} hour${diffHours !== 1 ? "s" : ""} ago`;
31
- if (diffDays === 1)
32
- return "1 day ago";
31
+ return `${diffHours}h`;
33
32
  if (diffDays < 7)
34
- return `${diffDays} days ago`;
35
- return date.toLocaleDateString();
33
+ return `${diffDays}d`;
34
+ if (diffDays < 30)
35
+ return `${Math.floor(diffDays / 7)}w`;
36
+ if (diffDays < 365)
37
+ return `${Math.floor(diffDays / 30)}mo`;
38
+ return `${Math.floor(diffDays / 365)}y`;
36
39
  }
37
40
  class SessionSelectorHeader {
38
41
  scope;
39
42
  sortMode;
43
+ nameFilter;
44
+ keybindings;
40
45
  requestRender;
41
46
  loading = false;
42
47
  loadProgress = null;
@@ -45,9 +50,11 @@ class SessionSelectorHeader {
45
50
  statusMessage = null;
46
51
  statusTimeout = null;
47
52
  showRenameHint = false;
48
- constructor(scope, sortMode, requestRender) {
53
+ constructor(scope, sortMode, nameFilter, keybindings, requestRender) {
49
54
  this.scope = scope;
50
55
  this.sortMode = sortMode;
56
+ this.nameFilter = nameFilter;
57
+ this.keybindings = keybindings;
51
58
  this.requestRender = requestRender;
52
59
  }
53
60
  setScope(scope) {
@@ -56,6 +63,9 @@ class SessionSelectorHeader {
56
63
  setSortMode(sortMode) {
57
64
  this.sortMode = sortMode;
58
65
  }
66
+ setNameFilter(nameFilter) {
67
+ this.nameFilter = nameFilter;
68
+ }
59
69
  setLoading(loading) {
60
70
  this.loading = loading;
61
71
  // Progress is scoped to the current load; clear whenever the loading state is set
@@ -94,8 +104,10 @@ class SessionSelectorHeader {
94
104
  render(width) {
95
105
  const title = this.scope === "current" ? "Resume Session (Current Folder)" : "Resume Session (All)";
96
106
  const leftText = theme.bold(title);
97
- const sortLabel = this.sortMode === "recent" ? "Recent" : "Fuzzy";
107
+ const sortLabel = this.sortMode === "threaded" ? "Threaded" : this.sortMode === "recent" ? "Recent" : "Fuzzy";
98
108
  const sortText = theme.fg("muted", "Sort: ") + theme.fg("accent", sortLabel);
109
+ const nameLabel = this.nameFilter === "all" ? "All" : "Named";
110
+ const nameText = theme.fg("muted", "Name: ") + theme.fg("accent", nameLabel);
99
111
  let scopeText;
100
112
  if (this.loading) {
101
113
  const progressText = this.loadProgress ? `${this.loadProgress.loaded}/${this.loadProgress.total}` : "...";
@@ -107,7 +119,7 @@ class SessionSelectorHeader {
107
119
  else {
108
120
  scopeText = `${theme.fg("muted", "○ Current Folder | ")}${theme.fg("accent", "◉ All")}`;
109
121
  }
110
- const rightText = truncateToWidth(`${scopeText} ${sortText}`, width, "");
122
+ const rightText = truncateToWidth(`${scopeText} ${nameText} ${sortText}`, width, "");
111
123
  const availableLeft = Math.max(0, width - visibleWidth(rightText) - 1);
112
124
  const left = truncateToWidth(leftText, availableLeft, "");
113
125
  const spacing = Math.max(0, width - visibleWidth(left) - visibleWidth(rightText));
@@ -130,6 +142,7 @@ class SessionSelectorHeader {
130
142
  const hint1 = keyHint("tab", "scope") + sep + theme.fg("muted", 're:<pattern> regex · "phrase" exact');
131
143
  const hint2Parts = [
132
144
  keyHint("toggleSessionSort", "sort"),
145
+ appKeyHint(this.keybindings, "toggleSessionNamedFilter", "named"),
133
146
  keyHint("deleteSession", "delete"),
134
147
  keyHint("toggleSessionPath", `path ${pathState}`),
135
148
  ];
@@ -143,20 +156,71 @@ class SessionSelectorHeader {
143
156
  return [`${left}${" ".repeat(spacing)}${rightText}`, hintLine1, hintLine2];
144
157
  }
145
158
  }
159
+ /**
160
+ * Build a tree structure from sessions based on parentSessionPath.
161
+ * Returns root nodes sorted by modified date (descending).
162
+ */
163
+ function buildSessionTree(sessions) {
164
+ const byPath = new Map();
165
+ for (const session of sessions) {
166
+ byPath.set(session.path, { session, children: [] });
167
+ }
168
+ const roots = [];
169
+ for (const session of sessions) {
170
+ const node = byPath.get(session.path);
171
+ const parentPath = session.parentSessionPath;
172
+ if (parentPath && byPath.has(parentPath)) {
173
+ byPath.get(parentPath).children.push(node);
174
+ }
175
+ else {
176
+ roots.push(node);
177
+ }
178
+ }
179
+ // Sort children and roots by modified date (descending)
180
+ const sortNodes = (nodes) => {
181
+ nodes.sort((a, b) => b.session.modified.getTime() - a.session.modified.getTime());
182
+ for (const node of nodes) {
183
+ sortNodes(node.children);
184
+ }
185
+ };
186
+ sortNodes(roots);
187
+ return roots;
188
+ }
189
+ /**
190
+ * Flatten tree into display list with tree structure metadata.
191
+ */
192
+ function flattenSessionTree(roots) {
193
+ const result = [];
194
+ const walk = (node, depth, ancestorContinues, isLast) => {
195
+ result.push({ session: node.session, depth, isLast, ancestorContinues });
196
+ for (let i = 0; i < node.children.length; i++) {
197
+ const childIsLast = i === node.children.length - 1;
198
+ // Only show continuation line for non-root ancestors
199
+ const continues = depth > 0 ? !isLast : false;
200
+ walk(node.children[i], depth + 1, [...ancestorContinues, continues], childIsLast);
201
+ }
202
+ };
203
+ for (let i = 0; i < roots.length; i++) {
204
+ walk(roots[i], 0, [], i === roots.length - 1);
205
+ }
206
+ return result;
207
+ }
146
208
  /**
147
209
  * Custom session list component with multi-line items and search
148
210
  */
149
211
  class SessionList {
150
212
  getSelectedSessionPath() {
151
213
  const selected = this.filteredSessions[this.selectedIndex];
152
- return selected?.path;
214
+ return selected?.session.path;
153
215
  }
154
216
  allSessions = [];
155
217
  filteredSessions = [];
156
218
  selectedIndex = 0;
157
219
  searchInput;
158
220
  showCwd = false;
159
- sortMode = "relevance";
221
+ sortMode = "threaded";
222
+ nameFilter = "all";
223
+ keybindings;
160
224
  showPath = false;
161
225
  confirmingDeletePath = null;
162
226
  currentSessionFilePath;
@@ -165,12 +229,13 @@ class SessionList {
165
229
  onExit = () => { };
166
230
  onToggleScope;
167
231
  onToggleSort;
232
+ onToggleNameFilter;
168
233
  onTogglePath;
169
234
  onDeleteConfirmationChange;
170
235
  onDeleteSession;
171
236
  onRenameSession;
172
237
  onError;
173
- maxVisible = 5; // Max sessions visible (each session: message + metadata + optional path + blank)
238
+ maxVisible = 10; // Max sessions visible (one line each)
174
239
  // Focusable implementation - propagate to searchInput for IME cursor positioning
175
240
  _focused = false;
176
241
  get focused() {
@@ -180,19 +245,22 @@ class SessionList {
180
245
  this._focused = value;
181
246
  this.searchInput.focused = value;
182
247
  }
183
- constructor(sessions, showCwd, sortMode, currentSessionFilePath) {
248
+ constructor(sessions, showCwd, sortMode, nameFilter, keybindings, currentSessionFilePath) {
184
249
  this.allSessions = sessions;
185
- this.filteredSessions = sessions;
250
+ this.filteredSessions = [];
186
251
  this.searchInput = new Input();
187
252
  this.showCwd = showCwd;
188
253
  this.sortMode = sortMode;
254
+ this.nameFilter = nameFilter;
255
+ this.keybindings = keybindings;
189
256
  this.currentSessionFilePath = currentSessionFilePath;
257
+ this.filterSessions("");
190
258
  // Handle Enter in search input - select current item
191
259
  this.searchInput.onSubmit = () => {
192
260
  if (this.filteredSessions[this.selectedIndex]) {
193
261
  const selected = this.filteredSessions[this.selectedIndex];
194
262
  if (this.onSelect) {
195
- this.onSelect(selected.path);
263
+ this.onSelect(selected.session.path);
196
264
  }
197
265
  }
198
266
  };
@@ -201,13 +269,33 @@ class SessionList {
201
269
  this.sortMode = sortMode;
202
270
  this.filterSessions(this.searchInput.getValue());
203
271
  }
272
+ setNameFilter(nameFilter) {
273
+ this.nameFilter = nameFilter;
274
+ this.filterSessions(this.searchInput.getValue());
275
+ }
204
276
  setSessions(sessions, showCwd) {
205
277
  this.allSessions = sessions;
206
278
  this.showCwd = showCwd;
207
279
  this.filterSessions(this.searchInput.getValue());
208
280
  }
209
281
  filterSessions(query) {
210
- this.filteredSessions = filterAndSortSessions(this.allSessions, query, this.sortMode);
282
+ const trimmed = query.trim();
283
+ const nameFiltered = this.nameFilter === "all" ? this.allSessions : this.allSessions.filter((session) => hasSessionName(session));
284
+ if (this.sortMode === "threaded" && !trimmed) {
285
+ // Threaded mode without search: show tree structure
286
+ const roots = buildSessionTree(nameFiltered);
287
+ this.filteredSessions = flattenSessionTree(roots);
288
+ }
289
+ else {
290
+ // Other modes or with search: flat list
291
+ const filtered = filterAndSortSessions(nameFiltered, query, this.sortMode, "all");
292
+ this.filteredSessions = filtered.map((session) => ({
293
+ session,
294
+ depth: 0,
295
+ isLast: true,
296
+ ancestorContinues: [],
297
+ }));
298
+ }
211
299
  this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1));
212
300
  }
213
301
  setConfirmingDeletePath(path) {
@@ -219,11 +307,11 @@ class SessionList {
219
307
  if (!selected)
220
308
  return;
221
309
  // Prevent deleting current session
222
- if (this.currentSessionFilePath && selected.path === this.currentSessionFilePath) {
310
+ if (this.currentSessionFilePath && selected.session.path === this.currentSessionFilePath) {
223
311
  this.onError?.("Cannot delete the currently active session");
224
312
  return;
225
313
  }
226
- this.setConfirmingDeletePath(selected.path);
314
+ this.setConfirmingDeletePath(selected.session.path);
227
315
  }
228
316
  invalidate() { }
229
317
  render(width) {
@@ -232,37 +320,68 @@ class SessionList {
232
320
  lines.push(...this.searchInput.render(width));
233
321
  lines.push(""); // Blank line after search
234
322
  if (this.filteredSessions.length === 0) {
235
- if (this.showCwd) {
323
+ let emptyMessage;
324
+ if (this.nameFilter === "named") {
325
+ const toggleKey = appKey(this.keybindings, "toggleSessionNamedFilter");
326
+ if (this.showCwd) {
327
+ emptyMessage = ` No named sessions found. Press ${toggleKey} to show all.`;
328
+ }
329
+ else {
330
+ emptyMessage = ` No named sessions in current folder. Press ${toggleKey} to show all, or Tab to view all.`;
331
+ }
332
+ }
333
+ else if (this.showCwd) {
236
334
  // "All" scope - no sessions anywhere that match filter
237
- lines.push(theme.fg("muted", truncateToWidth(" No sessions found", width, "…")));
335
+ emptyMessage = " No sessions found";
238
336
  }
239
337
  else {
240
338
  // "Current folder" scope - hint to try "all"
241
- lines.push(theme.fg("muted", truncateToWidth(" No sessions in current folder. Press Tab to view all.", width, "…")));
339
+ emptyMessage = " No sessions in current folder. Press Tab to view all.";
242
340
  }
341
+ lines.push(theme.fg("muted", truncateToWidth(emptyMessage, width, "…")));
243
342
  return lines;
244
343
  }
245
344
  // Calculate visible range with scrolling
246
345
  const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredSessions.length - this.maxVisible));
247
346
  const endIndex = Math.min(startIndex + this.maxVisible, this.filteredSessions.length);
248
- // Render visible sessions (message + metadata + optional path + blank line)
347
+ // Render visible sessions (one line each with tree structure)
249
348
  for (let i = startIndex; i < endIndex; i++) {
250
- const session = this.filteredSessions[i];
349
+ const node = this.filteredSessions[i];
350
+ const session = node.session;
251
351
  const isSelected = i === this.selectedIndex;
252
352
  const isConfirmingDelete = session.path === this.confirmingDeletePath;
253
- // Use session name if set, otherwise first message
353
+ const isCurrent = this.currentSessionFilePath === session.path;
354
+ // Build tree prefix
355
+ const prefix = this.buildTreePrefix(node);
356
+ // Session display text (name or first message)
254
357
  const hasName = !!session.name;
255
358
  const displayText = session.name ?? session.firstMessage;
256
359
  const normalizedMessage = displayText.replace(/\n/g, " ").trim();
257
- // First line: cursor + message (truncate to visible width)
258
- // Use warning color for custom names to distinguish from first message
360
+ // Right side: message count and age
361
+ const age = formatSessionDate(session.modified);
362
+ const msgCount = String(session.messageCount);
363
+ let rightPart = `${msgCount} ${age}`;
364
+ if (this.showCwd && session.cwd) {
365
+ rightPart = `${shortenPath(session.cwd)} ${rightPart}`;
366
+ }
367
+ if (this.showPath) {
368
+ rightPart = `${shortenPath(session.path)} ${rightPart}`;
369
+ }
370
+ // Cursor
259
371
  const cursor = isSelected ? theme.fg("accent", "› ") : " ";
260
- const maxMsgWidth = width - 2; // Account for cursor (2 visible chars)
261
- const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth, "...");
372
+ // Calculate available width for message
373
+ const prefixWidth = visibleWidth(prefix);
374
+ const rightWidth = visibleWidth(rightPart) + 2; // +2 for spacing
375
+ const availableForMsg = width - 2 - prefixWidth - rightWidth; // -2 for cursor
376
+ const truncatedMsg = truncateToWidth(normalizedMessage, Math.max(10, availableForMsg), "…");
377
+ // Style message
262
378
  let messageColor = null;
263
379
  if (isConfirmingDelete) {
264
380
  messageColor = "error";
265
381
  }
382
+ else if (isCurrent) {
383
+ messageColor = "accent";
384
+ }
266
385
  else if (hasName) {
267
386
  messageColor = "warning";
268
387
  }
@@ -270,27 +389,16 @@ class SessionList {
270
389
  if (isSelected) {
271
390
  styledMsg = theme.bold(styledMsg);
272
391
  }
273
- const messageLine = cursor + styledMsg;
274
- // Second line: metadata (dimmed) - also truncate for safety
275
- const modified = formatSessionDate(session.modified);
276
- const msgCount = `${session.messageCount} message${session.messageCount !== 1 ? "s" : ""}`;
277
- const metadataParts = [modified, msgCount];
278
- if (this.showCwd && session.cwd) {
279
- metadataParts.push(shortenPath(session.cwd));
280
- }
281
- const metadata = ` ${metadataParts.join(" · ")}`;
282
- const truncatedMetadata = truncateToWidth(metadata, width, "");
283
- const metadataLine = theme.fg(isConfirmingDelete ? "error" : "dim", truncatedMetadata);
284
- lines.push(messageLine);
285
- lines.push(metadataLine);
286
- // Optional third line: file path (when showPath is enabled)
287
- if (this.showPath) {
288
- const pathText = ` ${shortenPath(session.path)}`;
289
- const truncatedPath = truncateToWidth(pathText, width, "…");
290
- const pathLine = theme.fg(isConfirmingDelete ? "error" : "muted", truncatedPath);
291
- lines.push(pathLine);
392
+ // Build line
393
+ const leftPart = cursor + theme.fg("dim", prefix) + styledMsg;
394
+ const leftWidth = visibleWidth(leftPart);
395
+ const spacing = Math.max(1, width - leftWidth - visibleWidth(rightPart));
396
+ const styledRight = theme.fg(isConfirmingDelete ? "error" : "dim", rightPart);
397
+ let line = leftPart + " ".repeat(spacing) + styledRight;
398
+ if (isSelected) {
399
+ line = theme.bg("selectedBg", line);
292
400
  }
293
- lines.push(""); // Blank line between sessions
401
+ lines.push(truncateToWidth(line, width));
294
402
  }
295
403
  // Add scroll indicator if needed
296
404
  if (startIndex > 0 || endIndex < this.filteredSessions.length) {
@@ -300,6 +408,14 @@ class SessionList {
300
408
  }
301
409
  return lines;
302
410
  }
411
+ buildTreePrefix(node) {
412
+ if (node.depth === 0) {
413
+ return "";
414
+ }
415
+ const parts = node.ancestorContinues.map((continues) => (continues ? "│ " : " "));
416
+ const branch = node.isLast ? "└─ " : "├─ ";
417
+ return parts.join("") + branch;
418
+ }
303
419
  handleInput(keyData) {
304
420
  const kb = getEditorKeybindings();
305
421
  // Handle delete confirmation state first - intercept all keys
@@ -328,6 +444,10 @@ class SessionList {
328
444
  this.onToggleSort?.();
329
445
  return;
330
446
  }
447
+ if (this.keybindings.matches(keyData, "toggleSessionNamedFilter")) {
448
+ this.onToggleNameFilter?.();
449
+ return;
450
+ }
331
451
  // Ctrl+P: toggle path display
332
452
  if (kb.matches(keyData, "toggleSessionPath")) {
333
453
  this.showPath = !this.showPath;
@@ -343,7 +463,7 @@ class SessionList {
343
463
  if (matchesKey(keyData, "ctrl+r")) {
344
464
  const selected = this.filteredSessions[this.selectedIndex];
345
465
  if (selected) {
346
- this.onRenameSession?.(selected.path);
466
+ this.onRenameSession?.(selected.session.path);
347
467
  }
348
468
  return;
349
469
  }
@@ -378,7 +498,7 @@ class SessionList {
378
498
  else if (kb.matches(keyData, "selectConfirm")) {
379
499
  const selected = this.filteredSessions[this.selectedIndex];
380
500
  if (selected && this.onSelect) {
381
- this.onSelect(selected.path);
501
+ this.onSelect(selected.session.path);
382
502
  }
383
503
  }
384
504
  // Escape - cancel
@@ -449,8 +569,10 @@ export class SessionSelectorComponent extends Container {
449
569
  canRename = true;
450
570
  sessionList;
451
571
  header;
572
+ keybindings;
452
573
  scope = "current";
453
- sortMode = "relevance";
574
+ sortMode = "threaded";
575
+ nameFilter = "all";
454
576
  currentSessions = null;
455
577
  allSessions = null;
456
578
  currentSessionsLoader;
@@ -492,17 +614,18 @@ export class SessionSelectorComponent extends Container {
492
614
  }
493
615
  constructor(currentSessionsLoader, allSessionsLoader, onSelect, onCancel, onExit, requestRender, options, currentSessionFilePath) {
494
616
  super();
617
+ this.keybindings = options?.keybindings ?? KeybindingsManager.create();
495
618
  this.currentSessionsLoader = currentSessionsLoader;
496
619
  this.allSessionsLoader = allSessionsLoader;
497
620
  this.onCancel = onCancel;
498
621
  this.requestRender = requestRender;
499
- this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.requestRender);
622
+ this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.nameFilter, this.keybindings, this.requestRender);
500
623
  const renameSession = options?.renameSession;
501
624
  this.renameSession = renameSession;
502
625
  this.canRename = !!renameSession;
503
626
  this.header.setShowRenameHint(options?.showRenameHint ?? this.canRename);
504
627
  // Create session list (starts empty, will be populated after load)
505
- this.sessionList = new SessionList([], false, this.sortMode, currentSessionFilePath);
628
+ this.sessionList = new SessionList([], false, this.sortMode, this.nameFilter, this.keybindings, currentSessionFilePath);
506
629
  this.buildBaseLayout(this.sessionList);
507
630
  this.renameInput.onSubmit = (value) => {
508
631
  void this.confirmRename(value);
@@ -523,6 +646,7 @@ export class SessionSelectorComponent extends Container {
523
646
  };
524
647
  this.sessionList.onToggleScope = () => this.toggleScope();
525
648
  this.sessionList.onToggleSort = () => this.toggleSortMode();
649
+ this.sessionList.onToggleNameFilter = () => this.toggleNameFilter();
526
650
  this.sessionList.onRenameSession = (sessionPath) => {
527
651
  if (!renameSession)
528
652
  return;
@@ -684,11 +808,18 @@ export class SessionSelectorComponent extends Container {
684
808
  }
685
809
  }
686
810
  toggleSortMode() {
687
- this.sortMode = this.sortMode === "recent" ? "relevance" : "recent";
811
+ // Cycle: threaded -> recent -> relevance -> threaded
812
+ this.sortMode = this.sortMode === "threaded" ? "recent" : this.sortMode === "recent" ? "relevance" : "threaded";
688
813
  this.header.setSortMode(this.sortMode);
689
814
  this.sessionList.setSortMode(this.sortMode);
690
815
  this.requestRender();
691
816
  }
817
+ toggleNameFilter() {
818
+ this.nameFilter = this.nameFilter === "all" ? "named" : "all";
819
+ this.header.setNameFilter(this.nameFilter);
820
+ this.sessionList.setNameFilter(this.nameFilter);
821
+ this.requestRender();
822
+ }
692
823
  async refreshSessionsAfterMutation() {
693
824
  await this.loadScope(this.scope, "refresh");
694
825
  }