gemiterm 2.1.1 → 2.2.0-beta.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.
@@ -0,0 +1,319 @@
1
+ import chalk from "chalk";
2
+ import figures from "@inquirer/figures";
3
+ import {
4
+ input,
5
+ confirm as inquirerConfirm,
6
+ select as inquirerSelect,
7
+ } from "@inquirer/prompts";
8
+ import {
9
+ createPrompt,
10
+ useState,
11
+ useKeypress,
12
+ useMemo,
13
+ makeTheme,
14
+ isUpKey,
15
+ isDownKey,
16
+ isEnterKey,
17
+ AbortPromptError,
18
+ ExitPromptError,
19
+ } from "@inquirer/core";
20
+ import { GemitermError } from "../../core/errors.ts";
21
+ import type { ChatInfo } from "../../core/types.ts";
22
+
23
+ export class NonInteractiveError extends GemitermError {
24
+ constructor(message: string) {
25
+ super(message);
26
+ this.name = "NonInteractiveError";
27
+ }
28
+ }
29
+
30
+ export class CancellationError extends GemitermError {
31
+ constructor(message: string) {
32
+ super(message);
33
+ this.name = "CancellationError";
34
+ }
35
+ }
36
+
37
+ let activeController = new AbortController();
38
+
39
+ export function getAbortSignal(): AbortSignal {
40
+ return activeController.signal;
41
+ }
42
+
43
+ export function abortActivePrompts(reason?: unknown): void {
44
+ activeController.abort(reason);
45
+ }
46
+
47
+ export function resetAbortController(): void {
48
+ activeController = new AbortController();
49
+ }
50
+
51
+ function requireTty(commandHint: string): void {
52
+ if (process.stdin.isTTY !== true) {
53
+ throw new NonInteractiveError(
54
+ `Interactive prompts are disabled in non-TTY contexts. ${commandHint}`,
55
+ );
56
+ }
57
+ }
58
+
59
+ function mapCancellation(error: unknown): never {
60
+ if (error instanceof ExitPromptError || error instanceof AbortPromptError) {
61
+ throw new CancellationError(error.message);
62
+ }
63
+ throw error;
64
+ }
65
+
66
+ const theme = makeTheme({
67
+ prefix: { idle: chalk.cyan("?"), done: chalk.green(figures.tick) },
68
+ style: {
69
+ error: (text: string) => chalk.red("> " + text),
70
+ keysHelpTip: (_keys: ReadonlyArray<readonly [string, string]>) => undefined,
71
+ description: (text: string) => chalk.cyan.dim(text),
72
+ disabled: (text: string) => chalk.dim(text),
73
+ },
74
+ });
75
+
76
+ export interface TextOptions {
77
+ message: string;
78
+ default?: string;
79
+ validate?: (value: string) => boolean | string | Promise<string | boolean>;
80
+ }
81
+
82
+ export async function text(opts: TextOptions): Promise<string> {
83
+ requireTty(`gemiterm new "Your message"`);
84
+ try {
85
+ return await input(
86
+ {
87
+ message: opts.message,
88
+ default: opts.default,
89
+ validate: opts.validate,
90
+ theme,
91
+ },
92
+ { signal: getAbortSignal() },
93
+ );
94
+ } catch (error) {
95
+ mapCancellation(error);
96
+ }
97
+ }
98
+
99
+ export interface ConfirmOptions {
100
+ message: string;
101
+ default?: boolean;
102
+ }
103
+
104
+ export async function confirm(opts: ConfirmOptions): Promise<boolean> {
105
+ requireTty(`gemiterm <subcommand> --force`);
106
+ try {
107
+ return await inquirerConfirm(
108
+ { message: opts.message, default: opts.default, theme },
109
+ { signal: getAbortSignal() },
110
+ );
111
+ } catch (error) {
112
+ mapCancellation(error);
113
+ }
114
+ }
115
+
116
+ export interface SelectChoice<T> {
117
+ value: T;
118
+ label: string;
119
+ description?: string;
120
+ disabled?: boolean | string;
121
+ }
122
+
123
+ export interface SelectOptions<T> {
124
+ message: string;
125
+ choices: ReadonlyArray<SelectChoice<T>>;
126
+ default?: T;
127
+ pageSize?: number;
128
+ }
129
+
130
+ export async function select<T>(opts: SelectOptions<T>): Promise<T> {
131
+ requireTty(`gemiterm <subcommand> --format json`);
132
+ const choices = opts.choices.map((c) => ({
133
+ value: c.value,
134
+ name: c.label,
135
+ description: c.description,
136
+ disabled: c.disabled,
137
+ }));
138
+ try {
139
+ return await inquirerSelect(
140
+ {
141
+ message: opts.message,
142
+ choices,
143
+ default: opts.default,
144
+ pageSize: opts.pageSize,
145
+ theme,
146
+ },
147
+ { signal: getAbortSignal() },
148
+ );
149
+ } catch (error) {
150
+ mapCancellation(error);
151
+ }
152
+ }
153
+
154
+ export type BrowserAction =
155
+ | "view"
156
+ | "export-markdown"
157
+ | "export-json"
158
+ | "copy-id"
159
+ | "back"
160
+ | "quit";
161
+
162
+ export type BrowserResult =
163
+ | { kind: "pick"; chat: ChatInfo; action: BrowserAction }
164
+ | { kind: "quit" };
165
+
166
+ export interface BrowserConfig {
167
+ chats: ReadonlyArray<ChatInfo>;
168
+ initialSort?: "recent" | "oldest" | "alpha";
169
+ }
170
+
171
+ function formatDate(timestamp: number): string {
172
+ return new Date(timestamp).toISOString().slice(0, 16).replace("T", " ");
173
+ }
174
+
175
+ const TITLE_MAX = 55;
176
+
177
+ export function truncateTitle(title: string): string {
178
+ if (title.length <= TITLE_MAX) return title;
179
+ return `${title.slice(0, TITLE_MAX - 1)}…`;
180
+ }
181
+
182
+ export const browserPrompt = createPrompt<BrowserResult, BrowserConfig>(
183
+ (config, done) => {
184
+ const [sort, setSort] = useState<"recent" | "oldest" | "alpha">(
185
+ config.initialSort ?? "recent",
186
+ );
187
+ const [profileFilter, setProfileFilter] = useState<"all" | string>("all");
188
+ const [favoritesOnly, setFavoritesOnly] = useState<boolean>(false);
189
+ const [active, setActive] = useState<number>(0);
190
+
191
+ const profileNames = useMemo(() => {
192
+ const seen = new Set<string>();
193
+ for (const c of config.chats) {
194
+ if (c.profile) seen.add(c.profile);
195
+ }
196
+ return Array.from(seen);
197
+ }, [config.chats]);
198
+
199
+ const filteredSorted = useMemo(() => {
200
+ const filtered = config.chats.filter((c) => {
201
+ if (profileFilter !== "all" && c.profile !== profileFilter) return false;
202
+ if (favoritesOnly && !c.isPinned) return false;
203
+ return true;
204
+ });
205
+ const sorted = [...filtered];
206
+ switch (sort) {
207
+ case "recent":
208
+ sorted.sort((a, b) => b.timestamp - a.timestamp);
209
+ break;
210
+ case "oldest":
211
+ sorted.sort((a, b) => a.timestamp - b.timestamp);
212
+ break;
213
+ case "alpha":
214
+ sorted.sort((a, b) => a.title.localeCompare(b.title));
215
+ break;
216
+ }
217
+ return sorted;
218
+ }, [config.chats, sort, profileFilter, favoritesOnly]);
219
+
220
+ useKeypress((key) => {
221
+ if (key.name === "s") {
222
+ const next =
223
+ sort === "recent" ? "oldest" : sort === "oldest" ? "alpha" : "recent";
224
+ setSort(next);
225
+ return;
226
+ }
227
+
228
+ if (key.name === "p") {
229
+ const cycle = ["all", ...profileNames];
230
+ if (cycle.length === 0) {
231
+ setProfileFilter("all");
232
+ } else {
233
+ const currentIndex = cycle.indexOf(profileFilter);
234
+ const nextIndex = (currentIndex + 1) % cycle.length;
235
+ setProfileFilter(cycle[nextIndex] ?? "all");
236
+ }
237
+ return;
238
+ }
239
+
240
+ if (key.name === "f") {
241
+ setFavoritesOnly(!favoritesOnly);
242
+ return;
243
+ }
244
+
245
+ const total = filteredSorted.length;
246
+
247
+ if (total === 0) {
248
+ if (key.name === "q" || key.name === "escape") {
249
+ done({ kind: "quit" });
250
+ }
251
+ return;
252
+ }
253
+
254
+ if (isEnterKey(key)) {
255
+ const chat = filteredSorted[active];
256
+ if (chat) {
257
+ done({ kind: "pick", chat, action: "back" });
258
+ }
259
+ return;
260
+ }
261
+
262
+ if (key.name === "q" || key.name === "escape") {
263
+ done({ kind: "quit" });
264
+ return;
265
+ }
266
+
267
+ if (isUpKey(key)) {
268
+ setActive(Math.max(0, active - 1));
269
+ return;
270
+ }
271
+
272
+ if (isDownKey(key)) {
273
+ setActive(Math.min(total - 1, active + 1));
274
+ return;
275
+ }
276
+ });
277
+
278
+ const titleBar = chalk.bold(
279
+ `Browse conversations (${filteredSorted.length} chats | Sort: ${sort} | Profile: ${profileFilter} | Favorites: ${favoritesOnly ? "on" : "off"})`,
280
+ );
281
+ const hintLine = chalk.dim(
282
+ "↑↓ navigate · s sort · p profile · f favorites · enter pick · q quit",
283
+ );
284
+
285
+ const renderRow = (item: ChatInfo, isActive: boolean): string => {
286
+ const cursor = isActive ? "> " : " ";
287
+ const id = chalk.dim(item.id);
288
+ const date = chalk.cyan(formatDate(item.timestamp));
289
+ const title = truncateTitle(item.title);
290
+ const pin = item.isPinned ? chalk.yellow("★") : "";
291
+ return `${cursor}${id} ${date} ${title} ${pin}`;
292
+ };
293
+
294
+ const safeActive = Math.min(active, Math.max(0, filteredSorted.length - 1));
295
+ const rows = filteredSorted
296
+ .map((chat, i) => renderRow(chat, i === safeActive))
297
+ .join("\n");
298
+
299
+ if (filteredSorted.length === 0) {
300
+ return [`${titleBar}\nNo conversations found.`, hintLine];
301
+ }
302
+
303
+ return [`${titleBar}\n${rows}`, hintLine];
304
+ },
305
+ );
306
+
307
+ export async function browser(config: BrowserConfig): Promise<BrowserResult> {
308
+ requireTty(
309
+ "gemiterm list -i requires a TTY; use --format json for machine-readable output",
310
+ );
311
+ try {
312
+ return await browserPrompt(config, { signal: getAbortSignal() });
313
+ } catch (error) {
314
+ if (error instanceof ExitPromptError || error instanceof AbortPromptError) {
315
+ throw new CancellationError(error.message);
316
+ }
317
+ throw error;
318
+ }
319
+ }
@@ -11,6 +11,7 @@ export interface ListChatsQueryPayload {
11
11
  offset?: number;
12
12
  search?: string;
13
13
  allProfiles?: boolean;
14
+ profile?: string;
14
15
  }
15
16
 
16
17
  export interface ListChatsQueryResult {
@@ -78,12 +79,14 @@ export class ListChatsQueryHandler
78
79
  }
79
80
 
80
81
  async handle(query: Query<ListChatsQueryPayload>): Promise<ListChatsQueryResult> {
81
- const { limit, offset, search, allProfiles } = extractPayload(query);
82
+ const { limit, offset, search, allProfiles, profile } = extractPayload(query);
82
83
  const options = { limit, offset, search };
83
84
  const client = this.getGeminiClient();
84
85
 
85
86
  let chats: ChatInfo[];
86
- if (allProfiles) {
87
+ if (profile) {
88
+ chats = await client.forProfile(profile).listChats(options);
89
+ } else if (allProfiles) {
87
90
  const profileNames = this.listProfiles();
88
91
  const results = await Promise.all(
89
92
  profileNames.map((name) => client.forProfile(name).listChats(options)),
@@ -53,7 +53,7 @@ export class GeminiClientService
53
53
  this.initPromise = this.client!.init({
54
54
  timeout: 300_000,
55
55
  autoClose: false,
56
- autoRefresh: true,
56
+ autoRefresh: false,
57
57
  refreshInterval: 540_000,
58
58
  });
59
59
  await this.initPromise;