indusagi-coding-agent 0.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 (240) hide show
  1. package/CHANGELOG.md +2249 -0
  2. package/README.md +546 -0
  3. package/dist/cli/args.js +282 -0
  4. package/dist/cli/config-selector.js +30 -0
  5. package/dist/cli/file-processor.js +78 -0
  6. package/dist/cli/list-models.js +91 -0
  7. package/dist/cli/session-picker.js +31 -0
  8. package/dist/cli.js +10 -0
  9. package/dist/config.js +158 -0
  10. package/dist/core/agent-session.js +2097 -0
  11. package/dist/core/auth-storage.js +278 -0
  12. package/dist/core/bash-executor.js +211 -0
  13. package/dist/core/compaction/branch-summarization.js +241 -0
  14. package/dist/core/compaction/compaction.js +606 -0
  15. package/dist/core/compaction/index.js +6 -0
  16. package/dist/core/compaction/utils.js +137 -0
  17. package/dist/core/diagnostics.js +1 -0
  18. package/dist/core/event-bus.js +24 -0
  19. package/dist/core/exec.js +70 -0
  20. package/dist/core/export-html/ansi-to-html.js +248 -0
  21. package/dist/core/export-html/index.js +221 -0
  22. package/dist/core/export-html/template.css +905 -0
  23. package/dist/core/export-html/template.html +54 -0
  24. package/dist/core/export-html/template.js +1549 -0
  25. package/dist/core/export-html/tool-renderer.js +56 -0
  26. package/dist/core/export-html/vendor/highlight.min.js +1213 -0
  27. package/dist/core/export-html/vendor/marked.min.js +6 -0
  28. package/dist/core/extensions/index.js +8 -0
  29. package/dist/core/extensions/loader.js +395 -0
  30. package/dist/core/extensions/runner.js +499 -0
  31. package/dist/core/extensions/types.js +31 -0
  32. package/dist/core/extensions/wrapper.js +101 -0
  33. package/dist/core/footer-data-provider.js +133 -0
  34. package/dist/core/index.js +8 -0
  35. package/dist/core/keybindings.js +140 -0
  36. package/dist/core/messages.js +122 -0
  37. package/dist/core/model-registry.js +454 -0
  38. package/dist/core/model-resolver.js +309 -0
  39. package/dist/core/package-manager.js +1142 -0
  40. package/dist/core/prompt-templates.js +250 -0
  41. package/dist/core/resource-loader.js +569 -0
  42. package/dist/core/sdk.js +225 -0
  43. package/dist/core/session-manager.js +1078 -0
  44. package/dist/core/settings-manager.js +430 -0
  45. package/dist/core/skills.js +339 -0
  46. package/dist/core/system-prompt.js +136 -0
  47. package/dist/core/timings.js +24 -0
  48. package/dist/core/tools/bash.js +226 -0
  49. package/dist/core/tools/edit-diff.js +242 -0
  50. package/dist/core/tools/edit.js +145 -0
  51. package/dist/core/tools/find.js +205 -0
  52. package/dist/core/tools/grep.js +238 -0
  53. package/dist/core/tools/index.js +60 -0
  54. package/dist/core/tools/ls.js +117 -0
  55. package/dist/core/tools/path-utils.js +52 -0
  56. package/dist/core/tools/read.js +165 -0
  57. package/dist/core/tools/truncate.js +204 -0
  58. package/dist/core/tools/write.js +77 -0
  59. package/dist/index.js +41 -0
  60. package/dist/main.js +565 -0
  61. package/dist/migrations.js +260 -0
  62. package/dist/modes/index.js +7 -0
  63. package/dist/modes/interactive/components/armin.js +328 -0
  64. package/dist/modes/interactive/components/assistant-message.js +86 -0
  65. package/dist/modes/interactive/components/bash-execution.js +155 -0
  66. package/dist/modes/interactive/components/bordered-loader.js +47 -0
  67. package/dist/modes/interactive/components/branch-summary-message.js +41 -0
  68. package/dist/modes/interactive/components/compaction-summary-message.js +42 -0
  69. package/dist/modes/interactive/components/config-selector.js +458 -0
  70. package/dist/modes/interactive/components/countdown-timer.js +27 -0
  71. package/dist/modes/interactive/components/custom-editor.js +61 -0
  72. package/dist/modes/interactive/components/custom-message.js +80 -0
  73. package/dist/modes/interactive/components/diff.js +132 -0
  74. package/dist/modes/interactive/components/dynamic-border.js +19 -0
  75. package/dist/modes/interactive/components/extension-editor.js +96 -0
  76. package/dist/modes/interactive/components/extension-input.js +54 -0
  77. package/dist/modes/interactive/components/extension-selector.js +70 -0
  78. package/dist/modes/interactive/components/footer.js +213 -0
  79. package/dist/modes/interactive/components/index.js +31 -0
  80. package/dist/modes/interactive/components/keybinding-hints.js +60 -0
  81. package/dist/modes/interactive/components/login-dialog.js +138 -0
  82. package/dist/modes/interactive/components/model-selector.js +253 -0
  83. package/dist/modes/interactive/components/oauth-selector.js +91 -0
  84. package/dist/modes/interactive/components/scoped-models-selector.js +262 -0
  85. package/dist/modes/interactive/components/session-selector-search.js +145 -0
  86. package/dist/modes/interactive/components/session-selector.js +698 -0
  87. package/dist/modes/interactive/components/settings-selector.js +250 -0
  88. package/dist/modes/interactive/components/show-images-selector.js +33 -0
  89. package/dist/modes/interactive/components/skill-invocation-message.js +44 -0
  90. package/dist/modes/interactive/components/theme-selector.js +43 -0
  91. package/dist/modes/interactive/components/thinking-selector.js +45 -0
  92. package/dist/modes/interactive/components/tool-execution.js +608 -0
  93. package/dist/modes/interactive/components/tree-selector.js +892 -0
  94. package/dist/modes/interactive/components/user-message-selector.js +109 -0
  95. package/dist/modes/interactive/components/user-message.js +15 -0
  96. package/dist/modes/interactive/components/visual-truncate.js +32 -0
  97. package/dist/modes/interactive/interactive-mode.js +3576 -0
  98. package/dist/modes/interactive/theme/dark.json +85 -0
  99. package/dist/modes/interactive/theme/light.json +84 -0
  100. package/dist/modes/interactive/theme/theme-schema.json +335 -0
  101. package/dist/modes/interactive/theme/theme.js +938 -0
  102. package/dist/modes/print-mode.js +96 -0
  103. package/dist/modes/rpc/rpc-client.js +390 -0
  104. package/dist/modes/rpc/rpc-mode.js +448 -0
  105. package/dist/modes/rpc/rpc-types.js +7 -0
  106. package/dist/utils/changelog.js +86 -0
  107. package/dist/utils/clipboard-image.js +116 -0
  108. package/dist/utils/clipboard.js +58 -0
  109. package/dist/utils/frontmatter.js +25 -0
  110. package/dist/utils/git.js +5 -0
  111. package/dist/utils/image-convert.js +34 -0
  112. package/dist/utils/image-resize.js +180 -0
  113. package/dist/utils/mime.js +25 -0
  114. package/dist/utils/photon.js +120 -0
  115. package/dist/utils/shell.js +164 -0
  116. package/dist/utils/sleep.js +16 -0
  117. package/dist/utils/tools-manager.js +186 -0
  118. package/docs/compaction.md +390 -0
  119. package/docs/custom-provider.md +538 -0
  120. package/docs/development.md +69 -0
  121. package/docs/extensions.md +1733 -0
  122. package/docs/images/doom-extension.png +0 -0
  123. package/docs/images/interactive-mode.png +0 -0
  124. package/docs/images/tree-view.png +0 -0
  125. package/docs/json.md +79 -0
  126. package/docs/keybindings.md +162 -0
  127. package/docs/models.md +193 -0
  128. package/docs/packages.md +163 -0
  129. package/docs/prompt-templates.md +67 -0
  130. package/docs/providers.md +147 -0
  131. package/docs/rpc.md +1048 -0
  132. package/docs/sdk.md +957 -0
  133. package/docs/session.md +412 -0
  134. package/docs/settings.md +216 -0
  135. package/docs/shell-aliases.md +13 -0
  136. package/docs/skills.md +226 -0
  137. package/docs/terminal-setup.md +65 -0
  138. package/docs/themes.md +295 -0
  139. package/docs/tree.md +219 -0
  140. package/docs/tui.md +887 -0
  141. package/docs/windows.md +17 -0
  142. package/examples/README.md +25 -0
  143. package/examples/extensions/README.md +192 -0
  144. package/examples/extensions/antigravity-image-gen.ts +414 -0
  145. package/examples/extensions/auto-commit-on-exit.ts +49 -0
  146. package/examples/extensions/bookmark.ts +50 -0
  147. package/examples/extensions/claude-rules.ts +86 -0
  148. package/examples/extensions/confirm-destructive.ts +59 -0
  149. package/examples/extensions/custom-compaction.ts +115 -0
  150. package/examples/extensions/custom-footer.ts +65 -0
  151. package/examples/extensions/custom-header.ts +73 -0
  152. package/examples/extensions/custom-provider-anthropic/index.ts +605 -0
  153. package/examples/extensions/custom-provider-anthropic/package-lock.json +24 -0
  154. package/examples/extensions/custom-provider-anthropic/package.json +19 -0
  155. package/examples/extensions/custom-provider-gitlab-duo/index.ts +350 -0
  156. package/examples/extensions/custom-provider-gitlab-duo/package.json +16 -0
  157. package/examples/extensions/custom-provider-gitlab-duo/test.ts +83 -0
  158. package/examples/extensions/dirty-repo-guard.ts +56 -0
  159. package/examples/extensions/doom-overlay/README.md +46 -0
  160. package/examples/extensions/doom-overlay/doom/build/doom.js +21 -0
  161. package/examples/extensions/doom-overlay/doom/build/doom.wasm +0 -0
  162. package/examples/extensions/doom-overlay/doom/build.sh +152 -0
  163. package/examples/extensions/doom-overlay/doom/doomgeneric_pi.c +72 -0
  164. package/examples/extensions/doom-overlay/doom-component.ts +133 -0
  165. package/examples/extensions/doom-overlay/doom-engine.ts +173 -0
  166. package/examples/extensions/doom-overlay/doom-keys.ts +105 -0
  167. package/examples/extensions/doom-overlay/index.ts +74 -0
  168. package/examples/extensions/doom-overlay/wad-finder.ts +51 -0
  169. package/examples/extensions/event-bus.ts +43 -0
  170. package/examples/extensions/file-trigger.ts +41 -0
  171. package/examples/extensions/git-checkpoint.ts +53 -0
  172. package/examples/extensions/handoff.ts +151 -0
  173. package/examples/extensions/hello.ts +25 -0
  174. package/examples/extensions/inline-bash.ts +94 -0
  175. package/examples/extensions/input-transform.ts +43 -0
  176. package/examples/extensions/interactive-shell.ts +196 -0
  177. package/examples/extensions/mac-system-theme.ts +47 -0
  178. package/examples/extensions/message-renderer.ts +60 -0
  179. package/examples/extensions/modal-editor.ts +86 -0
  180. package/examples/extensions/model-status.ts +31 -0
  181. package/examples/extensions/notify.ts +25 -0
  182. package/examples/extensions/overlay-qa-tests.ts +882 -0
  183. package/examples/extensions/overlay-test.ts +151 -0
  184. package/examples/extensions/permission-gate.ts +34 -0
  185. package/examples/extensions/pirate.ts +47 -0
  186. package/examples/extensions/plan-mode/README.md +65 -0
  187. package/examples/extensions/plan-mode/index.ts +341 -0
  188. package/examples/extensions/plan-mode/utils.ts +168 -0
  189. package/examples/extensions/preset.ts +399 -0
  190. package/examples/extensions/protected-paths.ts +30 -0
  191. package/examples/extensions/qna.ts +120 -0
  192. package/examples/extensions/question.ts +265 -0
  193. package/examples/extensions/questionnaire.ts +428 -0
  194. package/examples/extensions/rainbow-editor.ts +88 -0
  195. package/examples/extensions/sandbox/index.ts +318 -0
  196. package/examples/extensions/sandbox/package-lock.json +92 -0
  197. package/examples/extensions/sandbox/package.json +19 -0
  198. package/examples/extensions/send-user-message.ts +97 -0
  199. package/examples/extensions/session-name.ts +27 -0
  200. package/examples/extensions/shutdown-command.ts +63 -0
  201. package/examples/extensions/snake.ts +344 -0
  202. package/examples/extensions/space-invaders.ts +561 -0
  203. package/examples/extensions/ssh.ts +220 -0
  204. package/examples/extensions/status-line.ts +40 -0
  205. package/examples/extensions/subagent/README.md +172 -0
  206. package/examples/extensions/subagent/agents/planner.md +37 -0
  207. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  208. package/examples/extensions/subagent/agents/scout.md +50 -0
  209. package/examples/extensions/subagent/agents/worker.md +24 -0
  210. package/examples/extensions/subagent/agents.ts +127 -0
  211. package/examples/extensions/subagent/index.ts +964 -0
  212. package/examples/extensions/subagent/prompts/implement-and-review.md +10 -0
  213. package/examples/extensions/subagent/prompts/implement.md +10 -0
  214. package/examples/extensions/subagent/prompts/scout-and-plan.md +9 -0
  215. package/examples/extensions/summarize.ts +196 -0
  216. package/examples/extensions/timed-confirm.ts +70 -0
  217. package/examples/extensions/todo.ts +300 -0
  218. package/examples/extensions/tool-override.ts +144 -0
  219. package/examples/extensions/tools.ts +147 -0
  220. package/examples/extensions/trigger-compact.ts +40 -0
  221. package/examples/extensions/truncated-tool.ts +193 -0
  222. package/examples/extensions/widget-placement.ts +17 -0
  223. package/examples/extensions/with-deps/index.ts +36 -0
  224. package/examples/extensions/with-deps/package-lock.json +31 -0
  225. package/examples/extensions/with-deps/package.json +22 -0
  226. package/examples/sdk/01-minimal.ts +22 -0
  227. package/examples/sdk/02-custom-model.ts +50 -0
  228. package/examples/sdk/03-custom-prompt.ts +55 -0
  229. package/examples/sdk/04-skills.ts +46 -0
  230. package/examples/sdk/05-tools.ts +56 -0
  231. package/examples/sdk/06-extensions.ts +88 -0
  232. package/examples/sdk/07-context-files.ts +40 -0
  233. package/examples/sdk/08-prompt-templates.ts +47 -0
  234. package/examples/sdk/09-api-keys-and-oauth.ts +48 -0
  235. package/examples/sdk/10-settings.ts +38 -0
  236. package/examples/sdk/11-sessions.ts +48 -0
  237. package/examples/sdk/12-full-control.ts +82 -0
  238. package/examples/sdk/13-codex-oauth.ts +37 -0
  239. package/examples/sdk/README.md +144 -0
  240. package/package.json +85 -0
@@ -0,0 +1,698 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { unlink } from "node:fs/promises";
4
+ import * as os from "node:os";
5
+ import { Container, getEditorKeybindings, Input, matchesKey, Spacer, Text, truncateToWidth, visibleWidth, } from "indusagi/tui";
6
+ import { theme } from "../theme/theme.js";
7
+ import { DynamicBorder } from "./dynamic-border.js";
8
+ import { keyHint } from "./keybinding-hints.js";
9
+ import { filterAndSortSessions } from "./session-selector-search.js";
10
+ function shortenPath(path) {
11
+ const home = os.homedir();
12
+ if (!path)
13
+ return path;
14
+ if (path.startsWith(home)) {
15
+ return `~${path.slice(home.length)}`;
16
+ }
17
+ return path;
18
+ }
19
+ function formatSessionDate(date) {
20
+ const now = new Date();
21
+ const diffMs = now.getTime() - date.getTime();
22
+ const diffMins = Math.floor(diffMs / 60000);
23
+ const diffHours = Math.floor(diffMs / 3600000);
24
+ const diffDays = Math.floor(diffMs / 86400000);
25
+ if (diffMins < 1)
26
+ return "just now";
27
+ if (diffMins < 60)
28
+ return `${diffMins} minute${diffMins !== 1 ? "s" : ""} ago`;
29
+ if (diffHours < 24)
30
+ return `${diffHours} hour${diffHours !== 1 ? "s" : ""} ago`;
31
+ if (diffDays === 1)
32
+ return "1 day ago";
33
+ if (diffDays < 7)
34
+ return `${diffDays} days ago`;
35
+ return date.toLocaleDateString();
36
+ }
37
+ class SessionSelectorHeader {
38
+ constructor(scope, sortMode, requestRender) {
39
+ this.loading = false;
40
+ this.loadProgress = null;
41
+ this.showPath = false;
42
+ this.confirmingDeletePath = null;
43
+ this.statusMessage = null;
44
+ this.statusTimeout = null;
45
+ this.showRenameHint = false;
46
+ this.scope = scope;
47
+ this.sortMode = sortMode;
48
+ this.requestRender = requestRender;
49
+ }
50
+ setScope(scope) {
51
+ this.scope = scope;
52
+ }
53
+ setSortMode(sortMode) {
54
+ this.sortMode = sortMode;
55
+ }
56
+ setLoading(loading) {
57
+ this.loading = loading;
58
+ // Progress is scoped to the current load; clear whenever the loading state is set
59
+ this.loadProgress = null;
60
+ }
61
+ setProgress(loaded, total) {
62
+ this.loadProgress = { loaded, total };
63
+ }
64
+ setShowPath(showPath) {
65
+ this.showPath = showPath;
66
+ }
67
+ setShowRenameHint(show) {
68
+ this.showRenameHint = show;
69
+ }
70
+ setConfirmingDeletePath(path) {
71
+ this.confirmingDeletePath = path;
72
+ }
73
+ clearStatusTimeout() {
74
+ if (!this.statusTimeout)
75
+ return;
76
+ clearTimeout(this.statusTimeout);
77
+ this.statusTimeout = null;
78
+ }
79
+ setStatusMessage(msg, autoHideMs) {
80
+ this.clearStatusTimeout();
81
+ this.statusMessage = msg;
82
+ if (!msg || !autoHideMs)
83
+ return;
84
+ this.statusTimeout = setTimeout(() => {
85
+ this.statusMessage = null;
86
+ this.statusTimeout = null;
87
+ this.requestRender();
88
+ }, autoHideMs);
89
+ }
90
+ invalidate() { }
91
+ render(width) {
92
+ const title = this.scope === "current" ? "Resume Session (Current Folder)" : "Resume Session (All)";
93
+ const leftText = theme.bold(title);
94
+ const sortLabel = this.sortMode === "recent" ? "Recent" : "Fuzzy";
95
+ const sortText = theme.fg("muted", "Sort: ") + theme.fg("accent", sortLabel);
96
+ let scopeText;
97
+ if (this.loading) {
98
+ const progressText = this.loadProgress ? `${this.loadProgress.loaded}/${this.loadProgress.total}` : "...";
99
+ scopeText = `${theme.fg("muted", "○ Current Folder | ")}${theme.fg("accent", `Loading ${progressText}`)}`;
100
+ }
101
+ else if (this.scope === "current") {
102
+ scopeText = `${theme.fg("accent", "◉ Current Folder")}${theme.fg("muted", " | ○ All")}`;
103
+ }
104
+ else {
105
+ scopeText = `${theme.fg("muted", "○ Current Folder | ")}${theme.fg("accent", "◉ All")}`;
106
+ }
107
+ const rightText = truncateToWidth(`${scopeText} ${sortText}`, width, "");
108
+ const availableLeft = Math.max(0, width - visibleWidth(rightText) - 1);
109
+ const left = truncateToWidth(leftText, availableLeft, "");
110
+ const spacing = Math.max(0, width - visibleWidth(left) - visibleWidth(rightText));
111
+ // Build hint lines - changes based on state (all branches truncate to width)
112
+ let hintLine1;
113
+ let hintLine2;
114
+ if (this.confirmingDeletePath !== null) {
115
+ const confirmHint = "Delete session? [Enter] confirm · [Esc/Ctrl+C] cancel";
116
+ hintLine1 = theme.fg("error", truncateToWidth(confirmHint, width, "…"));
117
+ hintLine2 = "";
118
+ }
119
+ else if (this.statusMessage) {
120
+ const color = this.statusMessage.type === "error" ? "error" : "accent";
121
+ hintLine1 = theme.fg(color, truncateToWidth(this.statusMessage.message, width, "…"));
122
+ hintLine2 = "";
123
+ }
124
+ else {
125
+ const pathState = this.showPath ? "(on)" : "(off)";
126
+ const sep = theme.fg("muted", " · ");
127
+ const hint1 = keyHint("tab", "scope") + sep + theme.fg("muted", 're:<pattern> regex · "phrase" exact');
128
+ const hint2Parts = [
129
+ keyHint("toggleSessionSort", "sort"),
130
+ keyHint("deleteSession", "delete"),
131
+ keyHint("toggleSessionPath", `path ${pathState}`),
132
+ ];
133
+ if (this.showRenameHint) {
134
+ hint2Parts.push(keyHint("renameSession", "rename"));
135
+ }
136
+ const hint2 = hint2Parts.join(sep);
137
+ hintLine1 = truncateToWidth(hint1, width, "…");
138
+ hintLine2 = truncateToWidth(hint2, width, "…");
139
+ }
140
+ return [`${left}${" ".repeat(spacing)}${rightText}`, hintLine1, hintLine2];
141
+ }
142
+ }
143
+ /**
144
+ * Custom session list component with multi-line items and search
145
+ */
146
+ class SessionList {
147
+ getSelectedSessionPath() {
148
+ const selected = this.filteredSessions[this.selectedIndex];
149
+ return selected?.path;
150
+ }
151
+ get focused() {
152
+ return this._focused;
153
+ }
154
+ set focused(value) {
155
+ this._focused = value;
156
+ this.searchInput.focused = value;
157
+ }
158
+ constructor(sessions, showCwd, sortMode, currentSessionFilePath) {
159
+ this.allSessions = [];
160
+ this.filteredSessions = [];
161
+ this.selectedIndex = 0;
162
+ this.showCwd = false;
163
+ this.sortMode = "relevance";
164
+ this.showPath = false;
165
+ this.confirmingDeletePath = null;
166
+ this.onExit = () => { };
167
+ this.maxVisible = 5; // Max sessions visible (each session: message + metadata + optional path + blank)
168
+ // Focusable implementation - propagate to searchInput for IME cursor positioning
169
+ this._focused = false;
170
+ this.allSessions = sessions;
171
+ this.filteredSessions = sessions;
172
+ this.searchInput = new Input();
173
+ this.showCwd = showCwd;
174
+ this.sortMode = sortMode;
175
+ this.currentSessionFilePath = currentSessionFilePath;
176
+ // Handle Enter in search input - select current item
177
+ this.searchInput.onSubmit = () => {
178
+ if (this.filteredSessions[this.selectedIndex]) {
179
+ const selected = this.filteredSessions[this.selectedIndex];
180
+ if (this.onSelect) {
181
+ this.onSelect(selected.path);
182
+ }
183
+ }
184
+ };
185
+ }
186
+ setSortMode(sortMode) {
187
+ this.sortMode = sortMode;
188
+ this.filterSessions(this.searchInput.getValue());
189
+ }
190
+ setSessions(sessions, showCwd) {
191
+ this.allSessions = sessions;
192
+ this.showCwd = showCwd;
193
+ this.filterSessions(this.searchInput.getValue());
194
+ }
195
+ filterSessions(query) {
196
+ this.filteredSessions = filterAndSortSessions(this.allSessions, query, this.sortMode);
197
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1));
198
+ }
199
+ setConfirmingDeletePath(path) {
200
+ this.confirmingDeletePath = path;
201
+ this.onDeleteConfirmationChange?.(path);
202
+ }
203
+ startDeleteConfirmationForSelectedSession() {
204
+ const selected = this.filteredSessions[this.selectedIndex];
205
+ if (!selected)
206
+ return;
207
+ // Prevent deleting current session
208
+ if (this.currentSessionFilePath && selected.path === this.currentSessionFilePath) {
209
+ this.onError?.("Cannot delete the currently active session");
210
+ return;
211
+ }
212
+ this.setConfirmingDeletePath(selected.path);
213
+ }
214
+ invalidate() { }
215
+ render(width) {
216
+ const lines = [];
217
+ // Render search input
218
+ lines.push(...this.searchInput.render(width));
219
+ lines.push(""); // Blank line after search
220
+ if (this.filteredSessions.length === 0) {
221
+ if (this.showCwd) {
222
+ // "All" scope - no sessions anywhere that match filter
223
+ lines.push(theme.fg("muted", truncateToWidth(" No sessions found", width, "…")));
224
+ }
225
+ else {
226
+ // "Current folder" scope - hint to try "all"
227
+ lines.push(theme.fg("muted", truncateToWidth(" No sessions in current folder. Press Tab to view all.", width, "…")));
228
+ }
229
+ return lines;
230
+ }
231
+ // Calculate visible range with scrolling
232
+ const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredSessions.length - this.maxVisible));
233
+ const endIndex = Math.min(startIndex + this.maxVisible, this.filteredSessions.length);
234
+ // Render visible sessions (message + metadata + optional path + blank line)
235
+ for (let i = startIndex; i < endIndex; i++) {
236
+ const session = this.filteredSessions[i];
237
+ const isSelected = i === this.selectedIndex;
238
+ const isConfirmingDelete = session.path === this.confirmingDeletePath;
239
+ // Use session name if set, otherwise first message
240
+ const hasName = !!session.name;
241
+ const displayText = session.name ?? session.firstMessage;
242
+ const normalizedMessage = displayText.replace(/\n/g, " ").trim();
243
+ // First line: cursor + message (truncate to visible width)
244
+ // Use warning color for custom names to distinguish from first message
245
+ const cursor = isSelected ? theme.fg("accent", "› ") : " ";
246
+ const maxMsgWidth = width - 2; // Account for cursor (2 visible chars)
247
+ const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth, "...");
248
+ let messageColor = null;
249
+ if (isConfirmingDelete) {
250
+ messageColor = "error";
251
+ }
252
+ else if (hasName) {
253
+ messageColor = "warning";
254
+ }
255
+ let styledMsg = messageColor ? theme.fg(messageColor, truncatedMsg) : truncatedMsg;
256
+ if (isSelected) {
257
+ styledMsg = theme.bold(styledMsg);
258
+ }
259
+ const messageLine = cursor + styledMsg;
260
+ // Second line: metadata (dimmed) - also truncate for safety
261
+ const modified = formatSessionDate(session.modified);
262
+ const msgCount = `${session.messageCount} message${session.messageCount !== 1 ? "s" : ""}`;
263
+ const metadataParts = [modified, msgCount];
264
+ if (this.showCwd && session.cwd) {
265
+ metadataParts.push(shortenPath(session.cwd));
266
+ }
267
+ const metadata = ` ${metadataParts.join(" · ")}`;
268
+ const truncatedMetadata = truncateToWidth(metadata, width, "");
269
+ const metadataLine = theme.fg(isConfirmingDelete ? "error" : "dim", truncatedMetadata);
270
+ lines.push(messageLine);
271
+ lines.push(metadataLine);
272
+ // Optional third line: file path (when showPath is enabled)
273
+ if (this.showPath) {
274
+ const pathText = ` ${shortenPath(session.path)}`;
275
+ const truncatedPath = truncateToWidth(pathText, width, "…");
276
+ const pathLine = theme.fg(isConfirmingDelete ? "error" : "muted", truncatedPath);
277
+ lines.push(pathLine);
278
+ }
279
+ lines.push(""); // Blank line between sessions
280
+ }
281
+ // Add scroll indicator if needed
282
+ if (startIndex > 0 || endIndex < this.filteredSessions.length) {
283
+ const scrollText = ` (${this.selectedIndex + 1}/${this.filteredSessions.length})`;
284
+ const scrollInfo = theme.fg("muted", truncateToWidth(scrollText, width, ""));
285
+ lines.push(scrollInfo);
286
+ }
287
+ return lines;
288
+ }
289
+ handleInput(keyData) {
290
+ const kb = getEditorKeybindings();
291
+ // Handle delete confirmation state first - intercept all keys
292
+ if (this.confirmingDeletePath !== null) {
293
+ if (kb.matches(keyData, "selectConfirm")) {
294
+ const pathToDelete = this.confirmingDeletePath;
295
+ this.setConfirmingDeletePath(null);
296
+ void this.onDeleteSession?.(pathToDelete);
297
+ return;
298
+ }
299
+ // Allow both Escape and Ctrl+C to cancel (consistent with indusagi UX)
300
+ if (kb.matches(keyData, "selectCancel") || matchesKey(keyData, "ctrl+c")) {
301
+ this.setConfirmingDeletePath(null);
302
+ return;
303
+ }
304
+ // Ignore all other keys while confirming
305
+ return;
306
+ }
307
+ if (kb.matches(keyData, "tab")) {
308
+ if (this.onToggleScope) {
309
+ this.onToggleScope();
310
+ }
311
+ return;
312
+ }
313
+ if (kb.matches(keyData, "toggleSessionSort")) {
314
+ this.onToggleSort?.();
315
+ return;
316
+ }
317
+ // Ctrl+P: toggle path display
318
+ if (kb.matches(keyData, "toggleSessionPath")) {
319
+ this.showPath = !this.showPath;
320
+ this.onTogglePath?.(this.showPath);
321
+ return;
322
+ }
323
+ // Ctrl+D: initiate delete confirmation (useful on terminals that don't distinguish Ctrl+Backspace from Backspace)
324
+ if (kb.matches(keyData, "deleteSession")) {
325
+ this.startDeleteConfirmationForSelectedSession();
326
+ return;
327
+ }
328
+ // Ctrl+R: rename selected session
329
+ if (matchesKey(keyData, "ctrl+r")) {
330
+ const selected = this.filteredSessions[this.selectedIndex];
331
+ if (selected) {
332
+ this.onRenameSession?.(selected.path);
333
+ }
334
+ return;
335
+ }
336
+ // Ctrl+Backspace: non-invasive convenience alias for delete
337
+ // Only triggers deletion when the query is empty; otherwise it is forwarded to the input
338
+ if (kb.matches(keyData, "deleteSessionNoninvasive")) {
339
+ if (this.searchInput.getValue().length > 0) {
340
+ this.searchInput.handleInput(keyData);
341
+ this.filterSessions(this.searchInput.getValue());
342
+ return;
343
+ }
344
+ this.startDeleteConfirmationForSelectedSession();
345
+ return;
346
+ }
347
+ // Up arrow
348
+ if (kb.matches(keyData, "selectUp")) {
349
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
350
+ }
351
+ // Down arrow
352
+ else if (kb.matches(keyData, "selectDown")) {
353
+ this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + 1);
354
+ }
355
+ // Page up - jump up by maxVisible items
356
+ else if (kb.matches(keyData, "selectPageUp")) {
357
+ this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisible);
358
+ }
359
+ // Page down - jump down by maxVisible items
360
+ else if (kb.matches(keyData, "selectPageDown")) {
361
+ this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + this.maxVisible);
362
+ }
363
+ // Enter
364
+ else if (kb.matches(keyData, "selectConfirm")) {
365
+ const selected = this.filteredSessions[this.selectedIndex];
366
+ if (selected && this.onSelect) {
367
+ this.onSelect(selected.path);
368
+ }
369
+ }
370
+ // Escape - cancel
371
+ else if (kb.matches(keyData, "selectCancel")) {
372
+ if (this.onCancel) {
373
+ this.onCancel();
374
+ }
375
+ }
376
+ // Pass everything else to search input
377
+ else {
378
+ this.searchInput.handleInput(keyData);
379
+ this.filterSessions(this.searchInput.getValue());
380
+ }
381
+ }
382
+ }
383
+ /**
384
+ * Delete a session file, trying the `trash` CLI first, then falling back to unlink
385
+ */
386
+ async function deleteSessionFile(sessionPath) {
387
+ // Try `trash` first (if installed)
388
+ const trashArgs = sessionPath.startsWith("-") ? ["--", sessionPath] : [sessionPath];
389
+ const trashResult = spawnSync("trash", trashArgs, { encoding: "utf-8" });
390
+ const getTrashErrorHint = () => {
391
+ const parts = [];
392
+ if (trashResult.error) {
393
+ parts.push(trashResult.error.message);
394
+ }
395
+ const stderr = trashResult.stderr?.trim();
396
+ if (stderr) {
397
+ parts.push(stderr.split("\n")[0] ?? stderr);
398
+ }
399
+ if (parts.length === 0)
400
+ return null;
401
+ return `trash: ${parts.join(" · ").slice(0, 200)}`;
402
+ };
403
+ // If trash reports success, or the file is gone afterwards, treat it as successful
404
+ if (trashResult.status === 0 || !existsSync(sessionPath)) {
405
+ return { ok: true, method: "trash" };
406
+ }
407
+ // Fallback to permanent deletion
408
+ try {
409
+ await unlink(sessionPath);
410
+ return { ok: true, method: "unlink" };
411
+ }
412
+ catch (err) {
413
+ const unlinkError = err instanceof Error ? err.message : String(err);
414
+ const trashErrorHint = getTrashErrorHint();
415
+ const error = trashErrorHint ? `${unlinkError} (${trashErrorHint})` : unlinkError;
416
+ return { ok: false, method: "unlink", error };
417
+ }
418
+ }
419
+ /**
420
+ * Component that renders a session selector
421
+ */
422
+ export class SessionSelectorComponent extends Container {
423
+ handleInput(data) {
424
+ if (this.mode === "rename") {
425
+ const kb = getEditorKeybindings();
426
+ if (kb.matches(data, "selectCancel") || matchesKey(data, "ctrl+c")) {
427
+ this.exitRenameMode();
428
+ return;
429
+ }
430
+ this.renameInput.handleInput(data);
431
+ return;
432
+ }
433
+ this.sessionList.handleInput(data);
434
+ }
435
+ get focused() {
436
+ return this._focused;
437
+ }
438
+ set focused(value) {
439
+ this._focused = value;
440
+ this.sessionList.focused = value;
441
+ this.renameInput.focused = value;
442
+ if (value && this.mode === "rename") {
443
+ this.renameInput.focused = true;
444
+ }
445
+ }
446
+ buildBaseLayout(content, options) {
447
+ this.clear();
448
+ this.addChild(new Spacer(1));
449
+ this.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
450
+ this.addChild(new Spacer(1));
451
+ if (options?.showHeader ?? true) {
452
+ this.addChild(this.header);
453
+ this.addChild(new Spacer(1));
454
+ }
455
+ this.addChild(content);
456
+ this.addChild(new Spacer(1));
457
+ this.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
458
+ }
459
+ constructor(currentSessionsLoader, allSessionsLoader, onSelect, onCancel, onExit, requestRender, options, currentSessionFilePath) {
460
+ super();
461
+ this.canRename = true;
462
+ this.scope = "current";
463
+ this.sortMode = "relevance";
464
+ this.currentSessions = null;
465
+ this.allSessions = null;
466
+ this.currentLoading = false;
467
+ this.allLoading = false;
468
+ this.allLoadSeq = 0;
469
+ this.mode = "list";
470
+ this.renameInput = new Input();
471
+ this.renameTargetPath = null;
472
+ // Focusable implementation - propagate to sessionList for IME cursor positioning
473
+ this._focused = false;
474
+ this.currentSessionsLoader = currentSessionsLoader;
475
+ this.allSessionsLoader = allSessionsLoader;
476
+ this.onCancel = onCancel;
477
+ this.requestRender = requestRender;
478
+ this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.requestRender);
479
+ const renameSession = options?.renameSession;
480
+ this.renameSession = renameSession;
481
+ this.canRename = !!renameSession;
482
+ this.header.setShowRenameHint(options?.showRenameHint ?? this.canRename);
483
+ // Create session list (starts empty, will be populated after load)
484
+ this.sessionList = new SessionList([], false, this.sortMode, currentSessionFilePath);
485
+ this.buildBaseLayout(this.sessionList);
486
+ this.renameInput.onSubmit = (value) => {
487
+ void this.confirmRename(value);
488
+ };
489
+ // Ensure header status timeouts are cleared when leaving the selector
490
+ const clearStatusMessage = () => this.header.setStatusMessage(null);
491
+ this.sessionList.onSelect = (sessionPath) => {
492
+ clearStatusMessage();
493
+ onSelect(sessionPath);
494
+ };
495
+ this.sessionList.onCancel = () => {
496
+ clearStatusMessage();
497
+ onCancel();
498
+ };
499
+ this.sessionList.onExit = () => {
500
+ clearStatusMessage();
501
+ onExit();
502
+ };
503
+ this.sessionList.onToggleScope = () => this.toggleScope();
504
+ this.sessionList.onToggleSort = () => this.toggleSortMode();
505
+ this.sessionList.onRenameSession = (sessionPath) => {
506
+ if (!renameSession)
507
+ return;
508
+ if (this.scope === "current" && this.currentLoading)
509
+ return;
510
+ if (this.scope === "all" && this.allLoading)
511
+ return;
512
+ const sessions = this.scope === "all" ? (this.allSessions ?? []) : (this.currentSessions ?? []);
513
+ const session = sessions.find((s) => s.path === sessionPath);
514
+ this.enterRenameMode(sessionPath, session?.name);
515
+ };
516
+ // Sync list events to header
517
+ this.sessionList.onTogglePath = (showPath) => {
518
+ this.header.setShowPath(showPath);
519
+ this.requestRender();
520
+ };
521
+ this.sessionList.onDeleteConfirmationChange = (path) => {
522
+ this.header.setConfirmingDeletePath(path);
523
+ this.requestRender();
524
+ };
525
+ this.sessionList.onError = (msg) => {
526
+ this.header.setStatusMessage({ type: "error", message: msg }, 3000);
527
+ this.requestRender();
528
+ };
529
+ // Handle session deletion
530
+ this.sessionList.onDeleteSession = async (sessionPath) => {
531
+ const result = await deleteSessionFile(sessionPath);
532
+ if (result.ok) {
533
+ if (this.currentSessions) {
534
+ this.currentSessions = this.currentSessions.filter((s) => s.path !== sessionPath);
535
+ }
536
+ if (this.allSessions) {
537
+ this.allSessions = this.allSessions.filter((s) => s.path !== sessionPath);
538
+ }
539
+ const sessions = this.scope === "all" ? (this.allSessions ?? []) : (this.currentSessions ?? []);
540
+ const showCwd = this.scope === "all";
541
+ this.sessionList.setSessions(sessions, showCwd);
542
+ const msg = result.method === "trash" ? "Session moved to trash" : "Session deleted";
543
+ this.header.setStatusMessage({ type: "info", message: msg }, 2000);
544
+ await this.refreshSessionsAfterMutation();
545
+ }
546
+ else {
547
+ const errorMessage = result.error ?? "Unknown error";
548
+ this.header.setStatusMessage({ type: "error", message: `Failed to delete: ${errorMessage}` }, 3000);
549
+ }
550
+ this.requestRender();
551
+ };
552
+ // Start loading current sessions immediately
553
+ this.loadCurrentSessions();
554
+ }
555
+ loadCurrentSessions() {
556
+ void this.loadScope("current", "initial");
557
+ }
558
+ enterRenameMode(sessionPath, currentName) {
559
+ this.mode = "rename";
560
+ this.renameTargetPath = sessionPath;
561
+ this.renameInput.setValue(currentName ?? "");
562
+ this.renameInput.focused = true;
563
+ const panel = new Container();
564
+ panel.addChild(new Text(theme.bold("Rename Session"), 1, 0));
565
+ panel.addChild(new Spacer(1));
566
+ panel.addChild(this.renameInput);
567
+ panel.addChild(new Spacer(1));
568
+ panel.addChild(new Text(theme.fg("muted", "Enter to save · Esc/Ctrl+C to cancel"), 1, 0));
569
+ this.buildBaseLayout(panel, { showHeader: false });
570
+ this.requestRender();
571
+ }
572
+ exitRenameMode() {
573
+ this.mode = "list";
574
+ this.renameTargetPath = null;
575
+ this.buildBaseLayout(this.sessionList);
576
+ this.requestRender();
577
+ }
578
+ async confirmRename(value) {
579
+ const next = value.trim();
580
+ if (!next)
581
+ return;
582
+ const target = this.renameTargetPath;
583
+ if (!target) {
584
+ this.exitRenameMode();
585
+ return;
586
+ }
587
+ // Find current name for callback
588
+ const renameSession = this.renameSession;
589
+ if (!renameSession) {
590
+ this.exitRenameMode();
591
+ return;
592
+ }
593
+ try {
594
+ await renameSession(target, next);
595
+ await this.refreshSessionsAfterMutation();
596
+ }
597
+ finally {
598
+ this.exitRenameMode();
599
+ }
600
+ }
601
+ async loadScope(scope, reason) {
602
+ const showCwd = scope === "all";
603
+ // Mark loading
604
+ if (scope === "current") {
605
+ this.currentLoading = true;
606
+ }
607
+ else {
608
+ this.allLoading = true;
609
+ }
610
+ const seq = scope === "all" ? ++this.allLoadSeq : undefined;
611
+ this.header.setScope(scope);
612
+ this.header.setLoading(true);
613
+ this.requestRender();
614
+ const onProgress = (loaded, total) => {
615
+ if (scope !== this.scope)
616
+ return;
617
+ if (seq !== undefined && seq !== this.allLoadSeq)
618
+ return;
619
+ this.header.setProgress(loaded, total);
620
+ this.requestRender();
621
+ };
622
+ try {
623
+ const sessions = await (scope === "current"
624
+ ? this.currentSessionsLoader(onProgress)
625
+ : this.allSessionsLoader(onProgress));
626
+ if (scope === "current") {
627
+ this.currentSessions = sessions;
628
+ this.currentLoading = false;
629
+ }
630
+ else {
631
+ this.allSessions = sessions;
632
+ this.allLoading = false;
633
+ }
634
+ if (scope !== this.scope)
635
+ return;
636
+ if (seq !== undefined && seq !== this.allLoadSeq)
637
+ return;
638
+ this.header.setLoading(false);
639
+ this.sessionList.setSessions(sessions, showCwd);
640
+ this.requestRender();
641
+ if (scope === "all" && sessions.length === 0 && (this.currentSessions?.length ?? 0) === 0) {
642
+ this.onCancel();
643
+ }
644
+ }
645
+ catch (err) {
646
+ if (scope === "current") {
647
+ this.currentLoading = false;
648
+ }
649
+ else {
650
+ this.allLoading = false;
651
+ }
652
+ if (scope !== this.scope)
653
+ return;
654
+ if (seq !== undefined && seq !== this.allLoadSeq)
655
+ return;
656
+ const message = err instanceof Error ? err.message : String(err);
657
+ this.header.setLoading(false);
658
+ this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000);
659
+ if (reason === "initial") {
660
+ this.sessionList.setSessions([], showCwd);
661
+ }
662
+ this.requestRender();
663
+ }
664
+ }
665
+ toggleSortMode() {
666
+ this.sortMode = this.sortMode === "recent" ? "relevance" : "recent";
667
+ this.header.setSortMode(this.sortMode);
668
+ this.sessionList.setSortMode(this.sortMode);
669
+ this.requestRender();
670
+ }
671
+ async refreshSessionsAfterMutation() {
672
+ await this.loadScope(this.scope, "refresh");
673
+ }
674
+ toggleScope() {
675
+ if (this.scope === "current") {
676
+ this.scope = "all";
677
+ this.header.setScope(this.scope);
678
+ if (this.allSessions !== null) {
679
+ this.header.setLoading(false);
680
+ this.sessionList.setSessions(this.allSessions, true);
681
+ this.requestRender();
682
+ return;
683
+ }
684
+ if (!this.allLoading) {
685
+ void this.loadScope("all", "toggle");
686
+ }
687
+ return;
688
+ }
689
+ this.scope = "current";
690
+ this.header.setScope(this.scope);
691
+ this.header.setLoading(this.currentLoading);
692
+ this.sessionList.setSessions(this.currentSessions ?? [], false);
693
+ this.requestRender();
694
+ }
695
+ getSessionList() {
696
+ return this.sessionList;
697
+ }
698
+ }