parallel-codex-tui 0.2.8 → 0.2.9
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/README.md +9 -7
- package/dist/cli.js +5 -1
- package/dist/core/session-manager.js +285 -12
- package/dist/domain/schemas.js +6 -0
- package/dist/tui/App.js +189 -5
- package/dist/tui/InputBar.js +24 -4
- package/dist/tui/MainConversationsView.js +142 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Built with Codex-assisted development.
|
|
|
6
6
|
|
|
7
7
|
## Current Release
|
|
8
8
|
|
|
9
|
-
`v0.2.
|
|
9
|
+
`v0.2.9` is available from [npm](https://www.npmjs.com/package/parallel-codex-tui/v/0.2.9) and as a [GitHub Release](https://github.com/allendred/parallel-codex-tui/releases/tag/v0.2.9). `Ctrl+T`, then `C`, now opens every persisted Main conversation in the workspace. Restoring one switches the exact `conversation_id`, reloads only that conversation's file-backed history, and restores its archived native Codex or Claude session ids when they are available. `Ctrl+N` still creates an explicit, durable Main conversation boundary without deleting chat, Tasks, or Worker logs.
|
|
10
10
|
|
|
11
11
|
A real Claude acceptance retires the first native session, inserts 16 newer records so the secret is absent from the inline transcript, then proves a fresh Claude session can read the scoped snapshot and recover it. The same live TUI presses `Ctrl+N`, verifies the replacement session is retired, starts another native session whose prompt and archive exclude the secret, and then switches between two isolated workspaces. The bounded rollover fallback remains included: every Main call rebuilds recent context from canonical `chat.jsonl`, so a fresh Agent still receives the latest dialogue inside its current scope.
|
|
12
12
|
|
|
@@ -23,9 +23,9 @@ Highlights:
|
|
|
23
23
|
- `--diagnostics` and `Ctrl+X` export bounded, redacted support evidence without prompts, role instructions, command arguments, source files, or environment-variable values.
|
|
24
24
|
- Runtime preflight checks Codex, Claude, named third-party Providers, proxy reachability, workspace access, CLI capabilities, and native attach policy before work starts.
|
|
25
25
|
- Named Worker Providers support Codex-compatible, Claude-compatible, OpenAI-compatible, Anthropic-compatible, and custom generic commands with independent role, model, environment, permission, resume, and interactive settings.
|
|
26
|
-
- Worker overview, Feature board, collaboration timeline, Task
|
|
26
|
+
- Worker overview, Feature board, collaboration timeline, Task and Main conversation centers, status details, rendered Markdown/Diff/error logs, Unicode search, keyboard navigation, mouse scrolling, and configurable themes share one terminal UI system.
|
|
27
27
|
|
|
28
|
-
Release acceptance includes a real three-Feature Tetris task with parallel Actor/Critic waves and final integration review. A clean `v0.2.5` task also ran Codex Judge and Actor, a buffered Claude Critic that independently executed `node --test` and `npm test`, atomic integration, and a resumed Codex Judge that independently passed all seven acceptance criteria. Real Codex and Claude probes both proved fresh and same-session resume calls; Codex fresh and resume runs executed workspace writes with root-level `-a never`, and Claude automated sessions executed safe Bash tools with `auto` permissions. The semantic Router completed a live classification, and one TUI completed Main calls in two workspaces before restoring the first workspace without leaking chat state. PTY coverage runs in Apple Terminal, tmux, and Zellij profiles at narrow and wide sizes, including status/log equivalence, preserving the native output tail across status-detail round trips, and proving both inline and on-demand file memory after native-session rollover. The deterministic repository suite contains 1,
|
|
28
|
+
Release acceptance includes a real three-Feature Tetris task with parallel Actor/Critic waves and final integration review. A clean `v0.2.5` task also ran Codex Judge and Actor, a buffered Claude Critic that independently executed `node --test` and `npm test`, atomic integration, and a resumed Codex Judge that independently passed all seven acceptance criteria. Real Codex and Claude probes both proved fresh and same-session resume calls; Codex fresh and resume runs executed workspace writes with root-level `-a never`, and Claude automated sessions executed safe Bash tools with `auto` permissions. The semantic Router completed a live classification, and one TUI completed Main calls in two workspaces before restoring the first workspace without leaking chat state. PTY coverage runs in Apple Terminal, tmux, and Zellij profiles at narrow and wide sizes, including status/log equivalence, preserving the native output tail across status-detail round trips, and proving both inline and on-demand file memory after native-session rollover. The deterministic repository suite contains 1,305 tests across 129 files: 1,304 pass by default, while one quota-consuming real-Agent test is skipped and passes through `npm run test:real-agents`.
|
|
29
29
|
|
|
30
30
|
Real Provider probes depend on valid local CLI credentials. In particular, authenticate the Claude CLI before selecting a Claude-compatible Worker, then run `parallel-codex-tui --doctor --probe-agents` to prove fresh and resumed calls on that machine.
|
|
31
31
|
|
|
@@ -209,7 +209,7 @@ The doctor output includes `preview:` and `semantic:` ANSI swatch rows so you ca
|
|
|
209
209
|
- Router classification only receives the user request; workspace selection and session files are kept out of the router prompt.
|
|
210
210
|
- Simple requests stay in the main TUI flow and do not create Judge, Actor, or Critic workers.
|
|
211
211
|
- Consecutive simple requests reuse the main worker's native session across app restarts when the CLI exposes a session id. Every Main call also rebuilds a bounded fallback transcript from the workspace `chat.jsonl`: the current ordinary conversation and each Task are isolated, the just-submitted request is removed, and at most 12 recent records or 6,000 characters are injected. A separate scoped `main-<provider>/conversation.jsonl` snapshot keeps up to 200 records for details outside that inline window; Main is told to read it only when needed and to treat it as context rather than instructions. This keeps multi-turn context available when native resume must roll over to a fresh Agent session without inflating every prompt.
|
|
212
|
-
- The workspace chat transcript is appended to `.parallel-codex/sessions/main/chat.jsonl`; startup restores the latest 200 valid messages and skips isolated corrupt rows. Before the first explicit boundary, records without `conversation_id` remain the compatible legacy conversation. `Ctrl+N` writes `.parallel-codex/sessions/main/conversation.json`, tags later ordinary records with its id, retires every active Main native session, and leaves the complete transcript untouched. Main stores only the extracted final answer in new chat records while retaining the raw Codex/Claude process transcript in the Main Worker's `output.log`. Legacy Codex transcript records are reduced to their final answer at read time without changing the original JSONL. The shared tail reader reads JSONL backward in bounded chunks, so a long-running chat does not require loading its lifetime transcript and a partial or oversized final row cannot hide earlier valid messages.
|
|
212
|
+
- The workspace chat transcript is appended to `.parallel-codex/sessions/main/chat.jsonl`; startup restores the latest 200 valid messages and skips isolated corrupt rows. Before the first explicit boundary, records without `conversation_id` remain the compatible legacy conversation. `Ctrl+N` writes `.parallel-codex/sessions/main/conversation.json`, tags later ordinary records with its id, retires every active Main native session, and leaves the complete transcript untouched. Conversation metadata and reusable native session records are archived under `.parallel-codex/sessions/main/conversations/<conversation>/`; selecting an older conversation intentionally removes its retirement barrier and restores those exact Main bindings. Main stores only the extracted final answer in new chat records while retaining the raw Codex/Claude process transcript in the Main Worker's `output.log`. Legacy Codex transcript records are reduced to their final answer at read time without changing the original JSONL. The shared tail reader reads JSONL backward in bounded chunks, so a long-running chat does not require loading its lifetime transcript and a partial or oversized final row cannot hide earlier valid messages.
|
|
213
213
|
- Chat drafts support Unicode-safe Left/Right, Home/End, Backspace, and Delete editing. A single Up/Down recalls persisted user requests and returns to the exact unsent draft; Ctrl+Up/Down remains available, while repeated alternate-scroll arrow bursts continue to scroll chat. Long input stays on one row with the visible window centered around the logical cursor.
|
|
214
214
|
- Bracketed multiline paste stays in one draft instead of submitting the first line; logical line breaks and tabs appear as `↵` and `⇥` in the single-row input until the complete request is submitted.
|
|
215
215
|
- Complex requests create a session under `.parallel-codex/sessions/`.
|
|
@@ -467,7 +467,9 @@ For a failed, cancelled, or paused task, select an unfinished Feature and press
|
|
|
467
467
|
|
|
468
468
|
From Worker overview, press `C` to open the file-backed Actor/Critic collaboration timeline. It merges `dialogue/actor-critic.jsonl`, feature status, Critic findings, Actor replies, finding resolution, and Wave events into one chronological view. The timeline follows new file evidence automatically and shows verified `fixed`/`open` counts when resolution evidence exists. Up/Down selects a collaboration event; `Enter` opens its complete event detail, including artifact paths from dialogue, status, Critic findings, Actor replies, and finding resolution; `U` filters to unresolved feature evidence; `Tab` cycles all features and each individual feature; `R` refreshes immediately; the mouse wheel or PageUp/PageDown scrolls history; and `Esc` returns to Worker overview.
|
|
469
469
|
|
|
470
|
-
`Ctrl+T` opens the workspace's persisted Task sessions without exiting the outer TUI. The list shows status, creation time, turn and Worker counts, plus the number of distinct native sessions after deduplicating reused `engine + session_id` bindings; `>` marks the selected row and `*` marks the active Task. Use Up/Down, PageUp/PageDown, the mouse wheel, or `Tab` to select, Enter to restore, `I` to inspect the complete session hierarchy, `R` to rename with Unicode-safe cursor editing, `A` to archive or unarchive, `D` twice to confirm deletion, `E` to export, `H` to show or hide archived sessions, `Ctrl+N` to leave the active Task and start a fresh Main conversation, and `Esc` to return without losing the chat draft. Archived sessions are hidden by default, cannot be restored until unarchived, and are excluded from automatic startup selection. Archive, delete, and export require a terminal Task with no live task lease; the active Task cannot be archived or deleted. Exports are complete file-backed snapshots under `.parallel-codex/exports/<task>-<timestamp>/` with a versioned manifest and without transient lease files. Restoring a Task reloads its route, Workers, retry state, and recorded native session ids. The selected active Task is stored in `session-index.sqlite` and survives process and workspace switches; `Ctrl+N` persists an intentionally empty active-Task context instead of reopening an older Task on restart. SQLite schema changes run as ordered, transactional migrations. Existing catalogs receive a pre-migration snapshot, each successful startup/reindex refreshes a healthy backup, and an integrity failure restores that backup or rebuilds the catalog from authoritative Task files while preserving the corrupt copy for inspection. `meta.json` and the Worker files remain the rebuild authority.
|
|
470
|
+
`Ctrl+T` opens the workspace's persisted Task sessions without exiting the outer TUI. The list shows status, creation time, turn and Worker counts, plus the number of distinct native sessions after deduplicating reused `engine + session_id` bindings; `>` marks the selected row and `*` marks the active Task. Use Up/Down, PageUp/PageDown, the mouse wheel, or `Tab` to select, Enter to restore, `C` to open Main conversations, `I` to inspect the complete session hierarchy, `R` to rename with Unicode-safe cursor editing, `A` to archive or unarchive, `D` twice to confirm deletion, `E` to export, `H` to show or hide archived sessions, `Ctrl+N` to leave the active Task and start a fresh Main conversation, and `Esc` to return without losing the chat draft. Archived sessions are hidden by default, cannot be restored until unarchived, and are excluded from automatic startup selection. Archive, delete, and export require a terminal Task with no live task lease; the active Task cannot be archived or deleted. Exports are complete file-backed snapshots under `.parallel-codex/exports/<task>-<timestamp>/` with a versioned manifest and without transient lease files. Restoring a Task reloads its route, Workers, retry state, and recorded native session ids. The selected active Task is stored in `session-index.sqlite` and survives process and workspace switches; `Ctrl+N` persists an intentionally empty active-Task context instead of reopening an older Task on restart. SQLite schema changes run as ordered, transactional migrations. Existing catalogs receive a pre-migration snapshot, each successful startup/reindex refreshes a healthy backup, and an integrity failure restores that backup or rebuilds the catalog from authoritative Task files while preserving the corrupt copy for inspection. `meta.json` and the Worker files remain the rebuild authority.
|
|
471
|
+
|
|
472
|
+
The Main conversation list shows each scoped title, message count, distinct native session count, last activity, current `*` marker, and selected `>` marker. Use the same Up/Down, PageUp/PageDown, mouse-wheel, or `Tab` navigation; Enter restores the selected conversation, `N` starts a new one, `T` returns to Tasks, and `Esc` returns to chat. Restoration preserves every Task and Worker log, switches `conversation.json`, and rebuilds Main history from records carrying only the selected `conversation_id` (or the pre-boundary legacy scope). Before switching, active Main native sessions are archived and retired; archived bindings for the selected conversation are then restored for best-effort native resume. A brand-new empty legacy scope is not added to the catalog.
|
|
471
473
|
|
|
472
474
|
The Task detail view reads authoritative task files and presents `Project -> Task -> Turn -> Worker -> Native session`, including each request, role, engine, configured model, Worker state, native session id, working directory, and last activity time. Historical Workers from earlier turns remain selectable after same-task follow-ups. Use Up/Down or `Tab` to select a Worker, Enter to open its retained log, `C` or `Ctrl+O` to continue the original native session, `B` to ask the native CLI to fork it, `R` to refresh, and `Esc` to return to the Task list. Continue and fork restore the selected Worker's recorded cwd and writable roots while the outer TUI stays open. Codex and Claude have built-in fork commands; a generic CLI exposes `B` only when `interactive.forkArgs` is configured. The child fork is owned and persisted by the native CLI; the indexed parent session remains unchanged.
|
|
473
475
|
|
|
@@ -521,12 +523,12 @@ The release job installs npm `^11.5.1`, runs on Node `24.15.x`, publishes the pr
|
|
|
521
523
|
To publish a release, update `package.json` and `src/version.ts` to the same version, then push a matching tag:
|
|
522
524
|
|
|
523
525
|
```bash
|
|
524
|
-
VERSION=0.2.
|
|
526
|
+
VERSION=0.2.9
|
|
525
527
|
git tag "v$VERSION"
|
|
526
528
|
git push origin "v$VERSION"
|
|
527
529
|
```
|
|
528
530
|
|
|
529
|
-
You can also run the Release workflow manually and enter the same tag value. The release tag must match `package.json`; for example, package version `0.2.
|
|
531
|
+
You can also run the Release workflow manually and enter the same tag value. The release tag must match `package.json`; for example, package version `0.2.9` requires tag `v0.2.9`. Published tags such as `v0.2.8` are immutable and must not be moved or reused.
|
|
530
532
|
|
|
531
533
|
## Publishing Hygiene
|
|
532
534
|
|
package/dist/cli.js
CHANGED
|
@@ -127,7 +127,11 @@ async function main() {
|
|
|
127
127
|
records,
|
|
128
128
|
policy: routerDiagnosticsPolicy(latestConfig.router)
|
|
129
129
|
};
|
|
130
|
-
}, loadTaskSessions: (options) => state.runtime.index.listTasks(100, options),
|
|
130
|
+
}, loadTaskSessions: (options) => state.runtime.index.listTasks(100, options), loadMainConversations: () => state.runtime.sessions.listMainConversations(100), activateMainConversation: async (conversationId) => {
|
|
131
|
+
const restored = await state.runtime.sessions.activateMainConversation(conversationId);
|
|
132
|
+
await state.runtime.index.setActiveTaskId(null);
|
|
133
|
+
return restored;
|
|
134
|
+
}, loadTaskSessionDetails: (task) => loadPersistedTaskSessionDetails({
|
|
131
135
|
task,
|
|
132
136
|
taskDir: state.runtime.sessions.taskFromId(task.id).dir,
|
|
133
137
|
modelNames: Object.fromEntries(Object.entries(state.runtime.config.workers).map(([id, worker]) => [id, worker.model.name]))
|
|
@@ -11,7 +11,7 @@ import { loadCollaborationTimeline } from "./collaboration-timeline.js";
|
|
|
11
11
|
import { taskStateTransitionAllowed } from "./task-state-machine.js";
|
|
12
12
|
import { processIsAlive, readProcessStartToken } from "./process-identity.js";
|
|
13
13
|
import { claimTaskRunLease, inspectTaskRunLease, TaskRunLeaseConflictError, terminateOwnedWorkerProcess } from "./process-ownership.js";
|
|
14
|
-
import { ChatRecordSchema, MainConversationStateSchema, EventRecordSchema, FeatureStatusSchema, NativeSessionSchema, RouteDecisionSchema, RetiredNativeSessionSchema, TaskMetaSchema, TaskIdSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
|
|
14
|
+
import { ChatRecordSchema, ConversationIdSchema, MainConversationArchiveSchema, MainConversationStateSchema, EventRecordSchema, FeatureStatusSchema, NativeSessionSchema, RouteDecisionSchema, RetiredNativeSessionSchema, TaskMetaSchema, TaskIdSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
|
|
15
15
|
export class InterruptedTaskRecoveryBlockedError extends Error {
|
|
16
16
|
taskId;
|
|
17
17
|
blocks;
|
|
@@ -42,6 +42,9 @@ const ACTIVE_FEATURE_STATES = new Set([
|
|
|
42
42
|
]);
|
|
43
43
|
const PENDING_TURN_DIRECTORY = /^\.turn-(\d{4})-.+\.pending$/;
|
|
44
44
|
const PENDING_TASK_CREATION_CLAIM = /^\.(task-.+)\.creating\.json$/;
|
|
45
|
+
const MAIN_CONVERSATION_ARCHIVE_DIRECTORY = "conversations";
|
|
46
|
+
const LEGACY_MAIN_CONVERSATION_DIRECTORY = "legacy";
|
|
47
|
+
const SAFE_MAIN_WORKER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
45
48
|
const CompletionContractSchema = z.object({
|
|
46
49
|
version: z.literal(1),
|
|
47
50
|
final_judge_required: z.literal(true)
|
|
@@ -180,24 +183,91 @@ export class SessionManager {
|
|
|
180
183
|
}
|
|
181
184
|
return readJson(path, MainConversationStateSchema);
|
|
182
185
|
}
|
|
186
|
+
async listMainConversations(limit = 100) {
|
|
187
|
+
const boundedLimit = Number.isFinite(limit)
|
|
188
|
+
? Math.min(500, Math.max(0, Math.trunc(limit)))
|
|
189
|
+
: 100;
|
|
190
|
+
if (boundedLimit === 0) {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
const [current, records, archives] = await Promise.all([
|
|
194
|
+
this.readMainConversationState(),
|
|
195
|
+
readRecentJsonLines(join(this.mainSessionDir(), "chat.jsonl"), ChatRecordSchema, 10000, { filter: (record) => !record.task_id }),
|
|
196
|
+
this.readMainConversationArchives()
|
|
197
|
+
]);
|
|
198
|
+
const currentId = current?.id ?? null;
|
|
199
|
+
const conversations = new Map();
|
|
200
|
+
const ensureConversation = (id, createdAt, lastActivityAt = createdAt) => {
|
|
201
|
+
const key = mainConversationScopeKey(id);
|
|
202
|
+
const existing = conversations.get(key);
|
|
203
|
+
if (existing) {
|
|
204
|
+
if (createdAt < existing.createdAt) {
|
|
205
|
+
existing.createdAt = createdAt;
|
|
206
|
+
}
|
|
207
|
+
if (lastActivityAt > existing.lastActivityAt) {
|
|
208
|
+
existing.lastActivityAt = lastActivityAt;
|
|
209
|
+
}
|
|
210
|
+
return existing;
|
|
211
|
+
}
|
|
212
|
+
const summary = {
|
|
213
|
+
id,
|
|
214
|
+
title: id ? "Untitled conversation" : "Legacy conversation",
|
|
215
|
+
createdAt,
|
|
216
|
+
lastActivityAt,
|
|
217
|
+
messageCount: 0,
|
|
218
|
+
userMessageCount: 0,
|
|
219
|
+
nativeSessionCount: 0,
|
|
220
|
+
current: id === currentId
|
|
221
|
+
};
|
|
222
|
+
conversations.set(key, summary);
|
|
223
|
+
return summary;
|
|
224
|
+
};
|
|
225
|
+
for (const archive of archives) {
|
|
226
|
+
ensureConversation(archive.id, archive.created_at, archive.last_activated_at);
|
|
227
|
+
}
|
|
228
|
+
for (const record of records) {
|
|
229
|
+
const id = record.conversation_id ?? null;
|
|
230
|
+
const conversation = ensureConversation(id, record.time, record.time);
|
|
231
|
+
conversation.messageCount += 1;
|
|
232
|
+
if (record.from === "user") {
|
|
233
|
+
conversation.userMessageCount += 1;
|
|
234
|
+
if (conversation.title === "Untitled conversation" || conversation.title === "Legacy conversation") {
|
|
235
|
+
conversation.title = mainConversationTitle(record.text, id);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (current) {
|
|
240
|
+
ensureConversation(current.id, current.created_at, current.created_at).current = true;
|
|
241
|
+
}
|
|
242
|
+
else if (!conversations.has(LEGACY_MAIN_CONVERSATION_DIRECTORY)) {
|
|
243
|
+
const createdAt = this.now().toISOString();
|
|
244
|
+
ensureConversation(null, createdAt, createdAt).current = true;
|
|
245
|
+
}
|
|
246
|
+
const activeNativeSessions = await this.activeMainNativeSessionIds();
|
|
247
|
+
await Promise.all([...conversations.values()].map(async (conversation) => {
|
|
248
|
+
const archived = await this.archivedMainNativeSessionIds(conversation.id);
|
|
249
|
+
if (conversation.current) {
|
|
250
|
+
for (const sessionId of activeNativeSessions) {
|
|
251
|
+
archived.add(sessionId);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
conversation.nativeSessionCount = archived.size;
|
|
255
|
+
}));
|
|
256
|
+
return [...conversations.values()]
|
|
257
|
+
.sort((left, right) => (Number(right.current) - Number(left.current)
|
|
258
|
+
|| right.lastActivityAt.localeCompare(left.lastActivityAt)
|
|
259
|
+
|| mainConversationScopeKey(right.id).localeCompare(mainConversationScopeKey(left.id))))
|
|
260
|
+
.slice(0, boundedLimit);
|
|
261
|
+
}
|
|
183
262
|
async startNewMainConversation() {
|
|
184
263
|
const main = this.taskFromId("main");
|
|
185
264
|
await ensureDir(main.dir);
|
|
186
265
|
const lease = await this.claimTaskRunLease(main.dir);
|
|
187
266
|
return runWithLeaseFinalization("Main conversation reset", lease, async () => {
|
|
188
267
|
const previous = await this.readMainConversationState();
|
|
189
|
-
const entries = await readdir(main.dir, { withFileTypes: true });
|
|
190
|
-
for (const entry of entries) {
|
|
191
|
-
if (!entry.isDirectory()) {
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
const worker = { dir: join(main.dir, entry.name) };
|
|
195
|
-
const nativeSession = await this.readNativeSession(worker);
|
|
196
|
-
if (nativeSession?.scope === "main") {
|
|
197
|
-
await this.retireNativeSession(worker, "new Main conversation");
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
268
|
const createdAt = this.now();
|
|
269
|
+
const currentSummary = (await this.listMainConversations(500)).find((conversation) => conversation.current);
|
|
270
|
+
await this.archiveAndRetireMainConversation(main, previous?.id ?? null, currentSummary?.createdAt ?? previous?.created_at ?? createdAt.toISOString(), "new Main conversation", Boolean(previous) || (currentSummary?.messageCount ?? 0) > 0 || (currentSummary?.nativeSessionCount ?? 0) > 0);
|
|
201
271
|
const baseId = `conversation-${formatTaskTimestamp(createdAt)}-${this.randomId()}`;
|
|
202
272
|
const state = MainConversationStateSchema.parse({
|
|
203
273
|
version: 1,
|
|
@@ -206,10 +276,61 @@ export class SessionManager {
|
|
|
206
276
|
...(previous ? { previous_id: previous.id } : {})
|
|
207
277
|
});
|
|
208
278
|
await writeJson(join(main.dir, "conversation.json"), state);
|
|
279
|
+
await this.writeMainConversationArchive({
|
|
280
|
+
version: 1,
|
|
281
|
+
id: state.id,
|
|
282
|
+
created_at: state.created_at,
|
|
283
|
+
last_activated_at: state.created_at
|
|
284
|
+
});
|
|
209
285
|
await this.appendEvent(main, "main.conversation_started", `Started ${state.id}`);
|
|
210
286
|
return state;
|
|
211
287
|
});
|
|
212
288
|
}
|
|
289
|
+
async activateMainConversation(conversationId) {
|
|
290
|
+
const id = conversationId === null ? null : ConversationIdSchema.parse(conversationId);
|
|
291
|
+
const main = this.taskFromId("main");
|
|
292
|
+
await ensureDir(main.dir);
|
|
293
|
+
const lease = await this.claimTaskRunLease(main.dir);
|
|
294
|
+
return runWithLeaseFinalization("Main conversation restore", lease, async () => {
|
|
295
|
+
const conversations = await this.listMainConversations(500);
|
|
296
|
+
const target = conversations.find((conversation) => conversation.id === id);
|
|
297
|
+
if (!target) {
|
|
298
|
+
throw new Error(`Main conversation not found: ${id ?? "legacy"}`);
|
|
299
|
+
}
|
|
300
|
+
const current = await this.readMainConversationState();
|
|
301
|
+
const currentId = current?.id ?? null;
|
|
302
|
+
if (currentId === id) {
|
|
303
|
+
return { conversation: target, restoredNativeSessions: 0, changed: false };
|
|
304
|
+
}
|
|
305
|
+
const currentSummary = conversations.find((conversation) => conversation.current);
|
|
306
|
+
const activatedAt = this.now().toISOString();
|
|
307
|
+
await this.archiveAndRetireMainConversation(main, currentId, currentSummary?.createdAt ?? current?.created_at ?? activatedAt, "switch Main conversation", Boolean(current) || (currentSummary?.messageCount ?? 0) > 0 || (currentSummary?.nativeSessionCount ?? 0) > 0);
|
|
308
|
+
if (id) {
|
|
309
|
+
await writeJson(join(main.dir, "conversation.json"), MainConversationStateSchema.parse({
|
|
310
|
+
version: 1,
|
|
311
|
+
id,
|
|
312
|
+
created_at: target.createdAt,
|
|
313
|
+
...(current?.id ? { previous_id: current.id } : {})
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
await removeIfExists(join(main.dir, "conversation.json"));
|
|
318
|
+
}
|
|
319
|
+
await this.writeMainConversationArchive({
|
|
320
|
+
version: 1,
|
|
321
|
+
id,
|
|
322
|
+
created_at: target.createdAt,
|
|
323
|
+
last_activated_at: activatedAt
|
|
324
|
+
});
|
|
325
|
+
const restoredNativeSessions = await this.restoreMainConversationNativeSessions(main, id);
|
|
326
|
+
await this.appendEvent(main, "main.conversation_restored", `Restored ${id ?? "legacy conversation"}; ${restoredNativeSessions} native session(s)`);
|
|
327
|
+
const restored = (await this.listMainConversations(500)).find((conversation) => conversation.id === id);
|
|
328
|
+
if (!restored) {
|
|
329
|
+
throw new Error(`Restored Main conversation disappeared from its catalog: ${id ?? "legacy"}`);
|
|
330
|
+
}
|
|
331
|
+
return { conversation: restored, restoredNativeSessions, changed: true };
|
|
332
|
+
});
|
|
333
|
+
}
|
|
213
334
|
async reconcileInterruptedMainSession() {
|
|
214
335
|
const main = this.taskFromId("main");
|
|
215
336
|
if (!(await pathExists(main.dir))) {
|
|
@@ -803,6 +924,127 @@ export class SessionManager {
|
|
|
803
924
|
await this.clearWorkerStatusNativeSession(worker);
|
|
804
925
|
await this.index?.deleteNativeSession(this.taskIdFromWorkerDir(worker.dir), record.worker_id);
|
|
805
926
|
}
|
|
927
|
+
mainConversationArchiveDir(conversationId) {
|
|
928
|
+
return join(this.mainSessionDir(), MAIN_CONVERSATION_ARCHIVE_DIRECTORY, mainConversationScopeKey(conversationId));
|
|
929
|
+
}
|
|
930
|
+
async readMainConversationArchives() {
|
|
931
|
+
const root = join(this.mainSessionDir(), MAIN_CONVERSATION_ARCHIVE_DIRECTORY);
|
|
932
|
+
if (!(await pathExists(root))) {
|
|
933
|
+
return [];
|
|
934
|
+
}
|
|
935
|
+
const archives = [];
|
|
936
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
937
|
+
if (!entry.isDirectory()) {
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
const metadata = await readMainConversationArchiveIfValid(join(root, entry.name, "meta.json"));
|
|
941
|
+
if (metadata && mainConversationScopeKey(metadata.id) === entry.name) {
|
|
942
|
+
archives.push(metadata);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return archives;
|
|
946
|
+
}
|
|
947
|
+
async writeMainConversationArchive(metadata) {
|
|
948
|
+
const parsed = MainConversationArchiveSchema.parse(metadata);
|
|
949
|
+
const path = join(this.mainConversationArchiveDir(parsed.id), "meta.json");
|
|
950
|
+
const existing = await readMainConversationArchiveIfValid(path);
|
|
951
|
+
await writeJson(path, MainConversationArchiveSchema.parse({
|
|
952
|
+
...parsed,
|
|
953
|
+
created_at: existing && existing.created_at < parsed.created_at
|
|
954
|
+
? existing.created_at
|
|
955
|
+
: parsed.created_at
|
|
956
|
+
}));
|
|
957
|
+
}
|
|
958
|
+
async archiveAndRetireMainConversation(main, conversationId, createdAt, reason, persistEmpty = true) {
|
|
959
|
+
const activatedAt = this.now().toISOString();
|
|
960
|
+
const archiveDir = join(this.mainConversationArchiveDir(conversationId), "native-sessions");
|
|
961
|
+
const entries = await readdir(main.dir, { withFileTypes: true });
|
|
962
|
+
let archivedNativeSession = false;
|
|
963
|
+
for (const entry of entries) {
|
|
964
|
+
if (!entry.isDirectory() || entry.name === MAIN_CONVERSATION_ARCHIVE_DIRECTORY) {
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
const worker = { dir: join(main.dir, entry.name) };
|
|
968
|
+
const nativeSession = await this.readNativeSession(worker);
|
|
969
|
+
if (nativeSession?.scope !== "main"
|
|
970
|
+
|| nativeSession.role !== "main"
|
|
971
|
+
|| !safeMainWorkerId(nativeSession.worker_id)) {
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
await writeJson(join(archiveDir, `${nativeSession.worker_id}.json`), nativeSession);
|
|
975
|
+
await this.retireNativeSession(worker, reason);
|
|
976
|
+
archivedNativeSession = true;
|
|
977
|
+
}
|
|
978
|
+
if (persistEmpty || archivedNativeSession) {
|
|
979
|
+
await this.writeMainConversationArchive({
|
|
980
|
+
version: 1,
|
|
981
|
+
id: conversationId,
|
|
982
|
+
created_at: createdAt,
|
|
983
|
+
last_activated_at: activatedAt
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
async restoreMainConversationNativeSessions(main, conversationId) {
|
|
988
|
+
const archiveDir = join(this.mainConversationArchiveDir(conversationId), "native-sessions");
|
|
989
|
+
if (!(await pathExists(archiveDir))) {
|
|
990
|
+
return 0;
|
|
991
|
+
}
|
|
992
|
+
let restored = 0;
|
|
993
|
+
for (const entry of await readdir(archiveDir, { withFileTypes: true })) {
|
|
994
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) {
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
const record = await readNativeSessionIfValid(join(archiveDir, entry.name));
|
|
998
|
+
if (!record
|
|
999
|
+
|| record.scope !== "main"
|
|
1000
|
+
|| record.role !== "main"
|
|
1001
|
+
|| !safeMainWorkerId(record.worker_id)
|
|
1002
|
+
|| entry.name !== `${record.worker_id}.json`) {
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
const worker = { dir: join(main.dir, record.worker_id) };
|
|
1006
|
+
await ensureDir(worker.dir);
|
|
1007
|
+
await writeJson(join(worker.dir, "native-session.json"), record);
|
|
1008
|
+
await removeIfExists(join(worker.dir, "native-session.retired.json"));
|
|
1009
|
+
await this.syncNativeSessionProjection(worker, record);
|
|
1010
|
+
restored += 1;
|
|
1011
|
+
}
|
|
1012
|
+
return restored;
|
|
1013
|
+
}
|
|
1014
|
+
async archivedMainNativeSessionIds(conversationId) {
|
|
1015
|
+
const archiveDir = join(this.mainConversationArchiveDir(conversationId), "native-sessions");
|
|
1016
|
+
const ids = new Set();
|
|
1017
|
+
if (!(await pathExists(archiveDir))) {
|
|
1018
|
+
return ids;
|
|
1019
|
+
}
|
|
1020
|
+
for (const entry of await readdir(archiveDir, { withFileTypes: true })) {
|
|
1021
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) {
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
const record = await readNativeSessionIfValid(join(archiveDir, entry.name));
|
|
1025
|
+
if (record?.scope === "main" && record.role === "main") {
|
|
1026
|
+
ids.add(`${record.engine}\u0000${record.session_id}`);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
return ids;
|
|
1030
|
+
}
|
|
1031
|
+
async activeMainNativeSessionIds() {
|
|
1032
|
+
const ids = new Set();
|
|
1033
|
+
const mainDir = this.mainSessionDir();
|
|
1034
|
+
if (!(await pathExists(mainDir))) {
|
|
1035
|
+
return ids;
|
|
1036
|
+
}
|
|
1037
|
+
for (const entry of await readdir(mainDir, { withFileTypes: true })) {
|
|
1038
|
+
if (!entry.isDirectory() || entry.name === MAIN_CONVERSATION_ARCHIVE_DIRECTORY) {
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
const record = await this.readNativeSession({ dir: join(mainDir, entry.name) });
|
|
1042
|
+
if (record?.scope === "main" && record.role === "main") {
|
|
1043
|
+
ids.add(`${record.engine}\u0000${record.session_id}`);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
return ids;
|
|
1047
|
+
}
|
|
806
1048
|
async appendEvent(task, type, message) {
|
|
807
1049
|
const event = {
|
|
808
1050
|
time: this.now().toISOString(),
|
|
@@ -1375,6 +1617,26 @@ function nextConversationId(baseId, previousId) {
|
|
|
1375
1617
|
? `${baseId}-${suffix + 1}`
|
|
1376
1618
|
: `${baseId}-2`;
|
|
1377
1619
|
}
|
|
1620
|
+
function mainConversationScopeKey(conversationId) {
|
|
1621
|
+
return conversationId ?? LEGACY_MAIN_CONVERSATION_DIRECTORY;
|
|
1622
|
+
}
|
|
1623
|
+
function mainConversationTitle(text, conversationId) {
|
|
1624
|
+
const sanitized = sanitizePersistedMainMessage("user", text)
|
|
1625
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
1626
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
1627
|
+
.replace(/\s+/g, " ")
|
|
1628
|
+
.trim();
|
|
1629
|
+
if (!sanitized) {
|
|
1630
|
+
return conversationId ? "Untitled conversation" : "Legacy conversation";
|
|
1631
|
+
}
|
|
1632
|
+
const characters = Array.from(sanitized);
|
|
1633
|
+
return characters.length > 80
|
|
1634
|
+
? `${characters.slice(0, 77).join("")}...`
|
|
1635
|
+
: sanitized;
|
|
1636
|
+
}
|
|
1637
|
+
function safeMainWorkerId(workerId) {
|
|
1638
|
+
return SAFE_MAIN_WORKER_ID.test(workerId) && workerId !== "." && workerId !== "..";
|
|
1639
|
+
}
|
|
1378
1640
|
function normalizeTaskTitle(title) {
|
|
1379
1641
|
const normalized = title
|
|
1380
1642
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
@@ -1486,6 +1748,17 @@ async function readRetiredNativeSessionIfValid(retiredSessionPath) {
|
|
|
1486
1748
|
return null;
|
|
1487
1749
|
}
|
|
1488
1750
|
}
|
|
1751
|
+
async function readMainConversationArchiveIfValid(path) {
|
|
1752
|
+
if (!(await pathExists(path))) {
|
|
1753
|
+
return null;
|
|
1754
|
+
}
|
|
1755
|
+
try {
|
|
1756
|
+
return await readJson(path, MainConversationArchiveSchema);
|
|
1757
|
+
}
|
|
1758
|
+
catch {
|
|
1759
|
+
return null;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1489
1762
|
async function readWorkerStatusIfValid(statusPath) {
|
|
1490
1763
|
if (!(await pathExists(statusPath))) {
|
|
1491
1764
|
return null;
|
package/dist/domain/schemas.js
CHANGED
|
@@ -205,6 +205,12 @@ export const MainConversationStateSchema = z.object({
|
|
|
205
205
|
created_at: z.string().datetime(),
|
|
206
206
|
previous_id: ConversationIdSchema.optional()
|
|
207
207
|
});
|
|
208
|
+
export const MainConversationArchiveSchema = z.object({
|
|
209
|
+
version: z.literal(1),
|
|
210
|
+
id: ConversationIdSchema.nullable(),
|
|
211
|
+
created_at: z.string().datetime(),
|
|
212
|
+
last_activated_at: z.string().datetime()
|
|
213
|
+
});
|
|
208
214
|
export const ChatRecordSchema = z.object({
|
|
209
215
|
time: z.string().datetime(),
|
|
210
216
|
from: z.enum(["user", "system"]),
|
package/dist/tui/App.js
CHANGED
|
@@ -29,6 +29,7 @@ import { moveWorkerSelection, WorkerOverviewView } from "./WorkerOverviewView.js
|
|
|
29
29
|
import { FeatureBoardView, moveFeatureBoardSelection } from "./FeatureBoardView.js";
|
|
30
30
|
import { CollaborationTimelineView, collaborationSelectionScrollOffset, collaborationTimelineEvents, moveCollaborationEventSelection, nextCollaborationFeatureIndex } from "./CollaborationTimelineView.js";
|
|
31
31
|
import { moveTaskSessionSelection, TaskSessionsView } from "./TaskSessionsView.js";
|
|
32
|
+
import { MainConversationsView, moveMainConversationSelection } from "./MainConversationsView.js";
|
|
32
33
|
import { moveTaskSessionDetailSelection, TaskSessionDetailView } from "./TaskSessionDetailView.js";
|
|
33
34
|
import { latestTaskResultMessageIndex, parseTaskResultSummary } from "./task-result.js";
|
|
34
35
|
import { buildNativeAttachLaunch, buildNativeForkLaunch, startNativeAttachProcess } from "../workers/native-attach.js";
|
|
@@ -49,7 +50,7 @@ const EMPTY_WORKER_NAVIGATION_TARGETS = {
|
|
|
49
50
|
errorOffsets: [],
|
|
50
51
|
diffOffsets: []
|
|
51
52
|
};
|
|
52
|
-
export function App({ config, orchestrator, cwd, initialTaskId = null, initialRoute = null, initialWorkers, initialCanRetryTask = false, initialMessages = [], persistChatMessage, reloadConfig, workspaceChoices = [], switchWorkspace, loadRouterDiagnostics, loadTaskSessions, loadTaskSessionDetails, renameTaskSession, setTaskSessionArchived, deleteTaskSession, exportTaskSession, exportDiagnostics, loadCollaborationTimeline, activateTaskSession, startMainConversation, prepareNativeAttach, prepareNativeFork, startNativeAttach, copyToClipboard = copyTextToClipboard, shutdownSignal }) {
|
|
53
|
+
export function App({ config, orchestrator, cwd, initialTaskId = null, initialRoute = null, initialWorkers, initialCanRetryTask = false, initialMessages = [], persistChatMessage, reloadConfig, workspaceChoices = [], switchWorkspace, loadRouterDiagnostics, loadTaskSessions, loadMainConversations, activateMainConversation, loadTaskSessionDetails, renameTaskSession, setTaskSessionArchived, deleteTaskSession, exportTaskSession, exportDiagnostics, loadCollaborationTimeline, activateTaskSession, startMainConversation, prepareNativeAttach, prepareNativeFork, startNativeAttach, copyToClipboard = copyTextToClipboard, shutdownSignal }) {
|
|
53
54
|
configureTuiTheme({
|
|
54
55
|
theme: config.ui.theme,
|
|
55
56
|
colors: config.ui.colors
|
|
@@ -97,6 +98,9 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
|
|
|
97
98
|
const [routerMaxScrollOffset, setRouterMaxScrollOffset] = useState(0);
|
|
98
99
|
const [taskSessions, setTaskSessions] = useState([]);
|
|
99
100
|
const [selectedTaskSessionIndex, setSelectedTaskSessionIndex] = useState(0);
|
|
101
|
+
const [sessionCenterMode, setSessionCenterMode] = useState("tasks");
|
|
102
|
+
const [mainConversations, setMainConversations] = useState([]);
|
|
103
|
+
const [selectedMainConversationIndex, setSelectedMainConversationIndex] = useState(0);
|
|
100
104
|
const [taskSessionsLoading, setTaskSessionsLoading] = useState(false);
|
|
101
105
|
const [taskSessionsError, setTaskSessionsError] = useState(null);
|
|
102
106
|
const [taskSessionsNotice, setTaskSessionsNotice] = useState(null);
|
|
@@ -149,6 +153,9 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
|
|
|
149
153
|
const routerMaxScrollOffsetRef = useRef(routerMaxScrollOffset);
|
|
150
154
|
const taskSessionsRef = useRef(taskSessions);
|
|
151
155
|
const selectedTaskSessionIndexRef = useRef(selectedTaskSessionIndex);
|
|
156
|
+
const sessionCenterModeRef = useRef("tasks");
|
|
157
|
+
const mainConversationsRef = useRef(mainConversations);
|
|
158
|
+
const selectedMainConversationIndexRef = useRef(selectedMainConversationIndex);
|
|
152
159
|
const taskSessionsLoadingRef = useRef(taskSessionsLoading);
|
|
153
160
|
const taskSessionsIncludeArchivedRef = useRef(taskSessionsIncludeArchived);
|
|
154
161
|
const taskSessionActionRef = useRef(taskSessionAction);
|
|
@@ -180,6 +187,8 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
|
|
|
180
187
|
const refreshCollaborationTimelineRef = useRef(refreshCollaborationTimeline);
|
|
181
188
|
const activateSelectedTaskSessionRef = useRef(activateSelectedTaskSession);
|
|
182
189
|
const refreshTaskSessionsRef = useRef(refreshTaskSessions);
|
|
190
|
+
const openMainConversationsRef = useRef(openMainConversations);
|
|
191
|
+
const restoreSelectedMainConversationRef = useRef(restoreSelectedMainConversation);
|
|
183
192
|
const renameSelectedTaskSessionRef = useRef(renameSelectedTaskSession);
|
|
184
193
|
const archiveSelectedTaskSessionRef = useRef(archiveSelectedTaskSession);
|
|
185
194
|
const deleteSelectedTaskSessionRef = useRef(deleteSelectedTaskSession);
|
|
@@ -334,6 +343,15 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
|
|
|
334
343
|
useEffect(() => {
|
|
335
344
|
selectedTaskSessionIndexRef.current = selectedTaskSessionIndex;
|
|
336
345
|
}, [selectedTaskSessionIndex]);
|
|
346
|
+
useEffect(() => {
|
|
347
|
+
sessionCenterModeRef.current = sessionCenterMode;
|
|
348
|
+
}, [sessionCenterMode]);
|
|
349
|
+
useEffect(() => {
|
|
350
|
+
mainConversationsRef.current = mainConversations;
|
|
351
|
+
}, [mainConversations]);
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
selectedMainConversationIndexRef.current = selectedMainConversationIndex;
|
|
354
|
+
}, [selectedMainConversationIndex]);
|
|
337
355
|
useEffect(() => {
|
|
338
356
|
taskSessionsLoadingRef.current = taskSessionsLoading;
|
|
339
357
|
}, [taskSessionsLoading]);
|
|
@@ -405,6 +423,8 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
|
|
|
405
423
|
refreshCollaborationTimelineRef.current = refreshCollaborationTimeline;
|
|
406
424
|
activateSelectedTaskSessionRef.current = activateSelectedTaskSession;
|
|
407
425
|
refreshTaskSessionsRef.current = refreshTaskSessions;
|
|
426
|
+
openMainConversationsRef.current = openMainConversations;
|
|
427
|
+
restoreSelectedMainConversationRef.current = restoreSelectedMainConversation;
|
|
408
428
|
renameSelectedTaskSessionRef.current = renameSelectedTaskSession;
|
|
409
429
|
archiveSelectedTaskSessionRef.current = archiveSelectedTaskSession;
|
|
410
430
|
deleteSelectedTaskSessionRef.current = deleteSelectedTaskSession;
|
|
@@ -685,6 +705,13 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
|
|
|
685
705
|
setTaskSessionsError(null);
|
|
686
706
|
setSelectedTaskSessionIndex(nextIndex);
|
|
687
707
|
};
|
|
708
|
+
const moveSelectedMainConversation = (delta, wrap = false) => {
|
|
709
|
+
const nextIndex = moveMainConversationSelection(selectedMainConversationIndexRef.current, delta, mainConversationsRef.current.length, wrap);
|
|
710
|
+
selectedMainConversationIndexRef.current = nextIndex;
|
|
711
|
+
setTaskSessionsError(null);
|
|
712
|
+
setTaskSessionsNotice(null);
|
|
713
|
+
setSelectedMainConversationIndex(nextIndex);
|
|
714
|
+
};
|
|
688
715
|
const moveSelectedTaskSessionDetailWorker = (delta, wrap = false) => {
|
|
689
716
|
const nextIndex = moveTaskSessionDetailSelection(taskSessionDetailSelectedWorkerIndexRef.current, delta, taskSessionDetailsRef.current?.workers.length ?? 0, wrap);
|
|
690
717
|
taskSessionDetailSelectedWorkerIndexRef.current = nextIndex;
|
|
@@ -847,6 +874,49 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
|
|
|
847
874
|
}
|
|
848
875
|
return;
|
|
849
876
|
}
|
|
877
|
+
if (sessionCenterModeRef.current === "conversations") {
|
|
878
|
+
if (isTaskSessionsShortcut(chunk, {}) || chunk === "\x1b") {
|
|
879
|
+
setTaskSessionsError(null);
|
|
880
|
+
setTaskSessionsNotice(null);
|
|
881
|
+
viewRef.current = taskSessionsReturnViewRef.current;
|
|
882
|
+
setView(taskSessionsReturnViewRef.current);
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (taskSessionsLoadingRef.current) {
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (isNewConversationShortcut(chunk, {}) || chunk === "n" || chunk === "N") {
|
|
889
|
+
void newConversationRef.current();
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (chunk === "t" || chunk === "T") {
|
|
893
|
+
sessionCenterModeRef.current = "tasks";
|
|
894
|
+
setSessionCenterMode("tasks");
|
|
895
|
+
setTaskSessionsError(null);
|
|
896
|
+
setTaskSessionsNotice(null);
|
|
897
|
+
void refreshTaskSessionsRef.current(activeTaskIdRef.current);
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if (chunk === "c" || chunk === "C") {
|
|
901
|
+
void openMainConversationsRef.current();
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
if (chunk === "\r" || chunk === "\n") {
|
|
905
|
+
void restoreSelectedMainConversationRef.current();
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
if (chunk === "\t") {
|
|
909
|
+
moveSelectedMainConversation(1, true);
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
const conversationSelectionDelta = -(rawHistoryDelta(chunk)
|
|
913
|
+
+ rawPageScrollDelta(chunk, Math.max(1, outputHeight - 2))
|
|
914
|
+
+ mouseScrollDelta(chunk, 1));
|
|
915
|
+
if (conversationSelectionDelta !== 0) {
|
|
916
|
+
moveSelectedMainConversation(conversationSelectionDelta);
|
|
917
|
+
}
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
850
920
|
const sessionAction = taskSessionActionRef.current;
|
|
851
921
|
if (sessionAction) {
|
|
852
922
|
if (chunk === "\x1b") {
|
|
@@ -902,6 +972,10 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
|
|
|
902
972
|
void newConversationRef.current();
|
|
903
973
|
return;
|
|
904
974
|
}
|
|
975
|
+
if (chunk === "c" || chunk === "C") {
|
|
976
|
+
void openMainConversationsRef.current();
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
905
979
|
const selectedSession = taskSessionsRef.current[selectedTaskSessionIndexRef.current];
|
|
906
980
|
if (chunk === "r" || chunk === "R") {
|
|
907
981
|
if (selectedSession) {
|
|
@@ -1682,7 +1756,15 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
|
|
|
1682
1756
|
label: "sessions",
|
|
1683
1757
|
text: details
|
|
1684
1758
|
? details.workers.map((worker) => [worker.id, worker.role, worker.engine, worker.state].join(" · ")).join("\n")
|
|
1685
|
-
:
|
|
1759
|
+
: sessionCenterModeRef.current === "conversations"
|
|
1760
|
+
? mainConversationsRef.current.map((conversation) => [
|
|
1761
|
+
conversation.current ? "current" : "saved",
|
|
1762
|
+
conversation.id ?? "legacy",
|
|
1763
|
+
conversation.title,
|
|
1764
|
+
`${conversation.messageCount} messages`,
|
|
1765
|
+
`${conversation.nativeSessionCount} native`
|
|
1766
|
+
].join(" · ")).join("\n")
|
|
1767
|
+
: taskSessionsRef.current.map((task) => [task.id, task.title, task.status].join(" · ")).join("\n")
|
|
1686
1768
|
};
|
|
1687
1769
|
}
|
|
1688
1770
|
if (currentView === "router") {
|
|
@@ -2110,6 +2192,10 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
|
|
|
2110
2192
|
setAttachError(error instanceof Error ? error.message : String(error));
|
|
2111
2193
|
return;
|
|
2112
2194
|
}
|
|
2195
|
+
resetActiveTaskUiForMainConversation();
|
|
2196
|
+
await appendVisibleMessage({ from: "system", text: "new conversation · ready" });
|
|
2197
|
+
}
|
|
2198
|
+
function resetActiveTaskUiForMainConversation() {
|
|
2113
2199
|
const nextMemory = newTaskMemoryState();
|
|
2114
2200
|
activeTaskIdRef.current = nextMemory.activeTaskId;
|
|
2115
2201
|
setActiveTaskId(nextMemory.activeTaskId);
|
|
@@ -2134,7 +2220,6 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
|
|
|
2134
2220
|
offset: 0,
|
|
2135
2221
|
draft: { value: inputRef.current, cursor: inputCursorRef.current }
|
|
2136
2222
|
};
|
|
2137
|
-
await appendVisibleMessage({ from: "system", text: "new conversation · ready" });
|
|
2138
2223
|
}
|
|
2139
2224
|
async function openRouterDiagnostics() {
|
|
2140
2225
|
const currentView = viewRef.current;
|
|
@@ -2200,6 +2285,8 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
|
|
|
2200
2285
|
setTaskSessionDetailSelectedWorkerIndex(0);
|
|
2201
2286
|
setTaskSessionDetailNotice(null);
|
|
2202
2287
|
updateTaskSessionAction(null);
|
|
2288
|
+
sessionCenterModeRef.current = "tasks";
|
|
2289
|
+
setSessionCenterMode("tasks");
|
|
2203
2290
|
await refreshTaskSessions(activeTaskIdRef.current);
|
|
2204
2291
|
}
|
|
2205
2292
|
async function refreshTaskSessions(preferredTaskId = null) {
|
|
@@ -2240,6 +2327,103 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
|
|
|
2240
2327
|
}
|
|
2241
2328
|
}
|
|
2242
2329
|
}
|
|
2330
|
+
async function openMainConversations() {
|
|
2331
|
+
const currentView = viewRef.current;
|
|
2332
|
+
if (busyRef.current || currentView === "native" || currentView === "workspace") {
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2335
|
+
if (currentView !== "sessions") {
|
|
2336
|
+
taskSessionsReturnViewRef.current = currentView === "worker" || currentView === "workers" || currentView === "router"
|
|
2337
|
+
? currentView
|
|
2338
|
+
: "chat";
|
|
2339
|
+
viewRef.current = "sessions";
|
|
2340
|
+
setView("sessions");
|
|
2341
|
+
}
|
|
2342
|
+
const previousId = sessionCenterModeRef.current === "conversations"
|
|
2343
|
+
? mainConversationsRef.current[selectedMainConversationIndexRef.current]?.id
|
|
2344
|
+
: undefined;
|
|
2345
|
+
sessionCenterModeRef.current = "conversations";
|
|
2346
|
+
setSessionCenterMode("conversations");
|
|
2347
|
+
taskSessionDetailsRef.current = null;
|
|
2348
|
+
setTaskSessionDetails(null);
|
|
2349
|
+
updateTaskSessionAction(null);
|
|
2350
|
+
setTaskSessionsError(null);
|
|
2351
|
+
setTaskSessionsNotice(null);
|
|
2352
|
+
taskSessionsLoadingRef.current = true;
|
|
2353
|
+
setTaskSessionsLoading(true);
|
|
2354
|
+
const sequence = taskSessionsLoadSequenceRef.current + 1;
|
|
2355
|
+
taskSessionsLoadSequenceRef.current = sequence;
|
|
2356
|
+
try {
|
|
2357
|
+
if (!loadMainConversations) {
|
|
2358
|
+
throw new Error("Main conversations are unavailable");
|
|
2359
|
+
}
|
|
2360
|
+
const conversations = await loadMainConversations();
|
|
2361
|
+
if (taskSessionsLoadSequenceRef.current !== sequence) {
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
mainConversationsRef.current = conversations;
|
|
2365
|
+
setMainConversations(conversations);
|
|
2366
|
+
const preservedIndex = previousId !== undefined
|
|
2367
|
+
? conversations.findIndex((conversation) => conversation.id === previousId)
|
|
2368
|
+
: -1;
|
|
2369
|
+
const currentIndex = conversations.findIndex((conversation) => conversation.current);
|
|
2370
|
+
const selectedIndex = preservedIndex >= 0
|
|
2371
|
+
? preservedIndex
|
|
2372
|
+
: currentIndex >= 0
|
|
2373
|
+
? currentIndex
|
|
2374
|
+
: 0;
|
|
2375
|
+
selectedMainConversationIndexRef.current = selectedIndex;
|
|
2376
|
+
setSelectedMainConversationIndex(selectedIndex);
|
|
2377
|
+
}
|
|
2378
|
+
catch (error) {
|
|
2379
|
+
if (taskSessionsLoadSequenceRef.current === sequence) {
|
|
2380
|
+
setTaskSessionsError(error instanceof Error ? error.message : String(error));
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
finally {
|
|
2384
|
+
if (taskSessionsLoadSequenceRef.current === sequence) {
|
|
2385
|
+
taskSessionsLoadingRef.current = false;
|
|
2386
|
+
setTaskSessionsLoading(false);
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
async function restoreSelectedMainConversation() {
|
|
2391
|
+
if (busyRef.current || taskSessionsLoadingRef.current) {
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
const selected = mainConversationsRef.current[selectedMainConversationIndexRef.current];
|
|
2395
|
+
if (!selected) {
|
|
2396
|
+
return;
|
|
2397
|
+
}
|
|
2398
|
+
taskSessionsLoadingRef.current = true;
|
|
2399
|
+
setTaskSessionsLoading(true);
|
|
2400
|
+
setTaskSessionsError(null);
|
|
2401
|
+
setTaskSessionsNotice(null);
|
|
2402
|
+
try {
|
|
2403
|
+
if (!activateMainConversation) {
|
|
2404
|
+
throw new Error("Main conversation restore is unavailable");
|
|
2405
|
+
}
|
|
2406
|
+
const result = await activateMainConversation(selected.id);
|
|
2407
|
+
if (!result.changed) {
|
|
2408
|
+
await openMainConversations();
|
|
2409
|
+
setTaskSessionsNotice(`Already current · ${result.conversation.title}`);
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
await activateTaskSession?.(null);
|
|
2413
|
+
resetActiveTaskUiForMainConversation();
|
|
2414
|
+
await appendVisibleMessage({
|
|
2415
|
+
from: "system",
|
|
2416
|
+
text: `conversation restored · ${result.conversation.title} · ${result.restoredNativeSessions} native`
|
|
2417
|
+
});
|
|
2418
|
+
}
|
|
2419
|
+
catch (error) {
|
|
2420
|
+
setTaskSessionsError(error instanceof Error ? error.message : String(error));
|
|
2421
|
+
}
|
|
2422
|
+
finally {
|
|
2423
|
+
taskSessionsLoadingRef.current = false;
|
|
2424
|
+
setTaskSessionsLoading(false);
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2243
2427
|
function updateTaskSessionAction(next) {
|
|
2244
2428
|
taskSessionActionRef.current = next;
|
|
2245
2429
|
setTaskSessionAction(next);
|
|
@@ -2727,11 +2911,11 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
|
|
|
2727
2911
|
? { type: "rename", value: taskSessionAction.value, cursor: taskSessionAction.cursor }
|
|
2728
2912
|
: taskSessionAction?.type === "delete"
|
|
2729
2913
|
? { type: "delete", title: taskSessionAction.title }
|
|
2730
|
-
: null, taskSessionsIncludeArchived: taskSessionsIncludeArchived, taskSessionDetail: Boolean(taskSessionDetails), taskSessionDetailHasNative: taskSessionDetailHasNative, taskSessionDetailCanFork: taskSessionDetailCanFork, canRetry: canRetryTask, hasWorkers: workers.length > 0, hasActiveTask: Boolean(activeTaskId), hasTaskResult: hasTaskResult, taskResultExpanded: taskResultExpanded, chatScrollOffset: chatScrollOffset, chatMaxScrollOffset: chatMaxScrollOffset, nativeClosed: view === "native" && nativeAttach?.closedCode !== null, searchMatchIndex: workerSearch.matchIndex, searchMatchCount: workerNavigationTargets.searchOffsets.length, clipboardNotice: clipboardNotice, value: workerSearch.open && view === "worker"
|
|
2914
|
+
: null, taskSessionsIncludeArchived: taskSessionsIncludeArchived, mainConversationSessions: sessionCenterMode === "conversations" && !taskSessionDetails, taskSessionDetail: Boolean(taskSessionDetails), taskSessionDetailHasNative: taskSessionDetailHasNative, taskSessionDetailCanFork: taskSessionDetailCanFork, canRetry: canRetryTask, hasWorkers: workers.length > 0, hasActiveTask: Boolean(activeTaskId), hasTaskResult: hasTaskResult, taskResultExpanded: taskResultExpanded, chatScrollOffset: chatScrollOffset, chatMaxScrollOffset: chatMaxScrollOffset, nativeClosed: view === "native" && nativeAttach?.closedCode !== null, searchMatchIndex: workerSearch.matchIndex, searchMatchCount: workerNavigationTargets.searchOffsets.length, clipboardNotice: clipboardNotice, value: workerSearch.open && view === "worker"
|
|
2731
2915
|
? workerSearch.query
|
|
2732
2916
|
: view === "native" || view === "router" || view === "workers" || view === "features" || view === "sessions" || view === "collaboration" || view === "status" ? "" : input, cursor: workerSearch.open && view === "worker"
|
|
2733
2917
|
? workerSearch.cursor
|
|
2734
|
-
: view === "chat" ? inputCursor : undefined, terminalWidth: terminalWidth, onChange: view === "native" ? setNativeInput : setInput, onSubmit: view === "native" ? undefined : submit }), error: attachError, children: view === "status" ? (_jsx(StatusDetailView, { cwd: cwd, taskId: activeTaskId, mode: activeMode, busy: busy, canRetry: canRetryTask, taskStatus: visibleTaskStatus, routeStatus: visibleRouteStatus, routeReason: routePending ? undefined : lastRoute?.reason, pairing: config.pairing, configStatus: configChange?.detail, configRestartRequired: configChange?.kind === "restart", workers: workers, selectedWorkerIndex: selectedWorkerIndex, height: contentHeight, terminalWidth: terminalWidth })) : view === "native" ? (_jsx(NativeAttachView, { attach: nativeAttach, viewportHeight: contentHeight })) : view === "sessions" ? (taskSessionDetails ? (_jsx(TaskSessionDetailView, { details: taskSessionDetails, selectedWorkerIndex: taskSessionDetailSelectedWorkerIndex, loading: taskSessionsLoading, error: taskSessionsError, notice: taskSessionDetailNotice, height: contentHeight, terminalWidth: terminalWidth })) : (_jsx(TaskSessionsView, { tasks: taskSessions, activeTaskId: activeTaskId, selectedIndex: selectedTaskSessionIndex, includeArchived: taskSessionsIncludeArchived, notice: taskSessionsNotice, action: taskSessionAction?.type === "rename"
|
|
2918
|
+
: view === "chat" ? inputCursor : undefined, terminalWidth: terminalWidth, onChange: view === "native" ? setNativeInput : setInput, onSubmit: view === "native" ? undefined : submit }), error: attachError, children: view === "status" ? (_jsx(StatusDetailView, { cwd: cwd, taskId: activeTaskId, mode: activeMode, busy: busy, canRetry: canRetryTask, taskStatus: visibleTaskStatus, routeStatus: visibleRouteStatus, routeReason: routePending ? undefined : lastRoute?.reason, pairing: config.pairing, configStatus: configChange?.detail, configRestartRequired: configChange?.kind === "restart", workers: workers, selectedWorkerIndex: selectedWorkerIndex, height: contentHeight, terminalWidth: terminalWidth })) : view === "native" ? (_jsx(NativeAttachView, { attach: nativeAttach, viewportHeight: contentHeight })) : view === "sessions" ? (taskSessionDetails ? (_jsx(TaskSessionDetailView, { details: taskSessionDetails, selectedWorkerIndex: taskSessionDetailSelectedWorkerIndex, loading: taskSessionsLoading, error: taskSessionsError, notice: taskSessionDetailNotice, height: contentHeight, terminalWidth: terminalWidth })) : sessionCenterMode === "conversations" ? (_jsx(MainConversationsView, { conversations: mainConversations, selectedIndex: selectedMainConversationIndex, notice: taskSessionsNotice, loading: taskSessionsLoading, error: taskSessionsError, height: contentHeight, terminalWidth: terminalWidth })) : (_jsx(TaskSessionsView, { tasks: taskSessions, activeTaskId: activeTaskId, selectedIndex: selectedTaskSessionIndex, includeArchived: taskSessionsIncludeArchived, notice: taskSessionsNotice, action: taskSessionAction?.type === "rename"
|
|
2735
2919
|
? { type: "rename", title: taskSessionAction.title }
|
|
2736
2920
|
: taskSessionAction?.type === "delete"
|
|
2737
2921
|
? { type: "delete", title: taskSessionAction.title }
|
package/dist/tui/InputBar.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import { compactEndByDisplayWidth, compactTailByDisplayWidth, displayWidth } from "./display-width.js";
|
|
4
4
|
import { TUI_THEME } from "./theme.js";
|
|
5
|
-
export function InputBar({ mode, ready = true, busy = false, routeFallback = false, collaborationDetail = false, collaborationUnresolved = false, collaborationBack = "workers", featureCanCancel = false, featureCanPause = false, featureCanReassign = false, featureCancelConfirm = false, featurePauseConfirm = false, featureAssignment = false, taskSessionAction = null, taskSessionsIncludeArchived = false, taskSessionDetail = false, taskSessionDetailHasNative = false, taskSessionDetailCanFork = false, canRetry = false, hasWorkers = false, hasActiveTask = false, hasTaskResult = false, taskResultExpanded = false, chatScrollOffset = 0, chatMaxScrollOffset = 0, nativeClosed = false, searchMatchIndex = 0, searchMatchCount = 0, clipboardNotice = null, value, cursor, terminalWidth: providedTerminalWidth, onChange, onSubmit }) {
|
|
5
|
+
export function InputBar({ mode, ready = true, busy = false, routeFallback = false, collaborationDetail = false, collaborationUnresolved = false, collaborationBack = "workers", featureCanCancel = false, featureCanPause = false, featureCanReassign = false, featureCancelConfirm = false, featurePauseConfirm = false, featureAssignment = false, taskSessionAction = null, taskSessionsIncludeArchived = false, mainConversationSessions = false, taskSessionDetail = false, taskSessionDetailHasNative = false, taskSessionDetailCanFork = false, canRetry = false, hasWorkers = false, hasActiveTask = false, hasTaskResult = false, taskResultExpanded = false, chatScrollOffset = 0, chatMaxScrollOffset = 0, nativeClosed = false, searchMatchIndex = 0, searchMatchCount = 0, clipboardNotice = null, value, cursor, terminalWidth: providedTerminalWidth, onChange, onSubmit }) {
|
|
6
6
|
const terminalWidth = providedTerminalWidth ?? process.stdout.columns ?? 120;
|
|
7
7
|
const fillRail = providedTerminalWidth !== undefined || typeof process.stdout.columns === "number";
|
|
8
8
|
if (clipboardNotice) {
|
|
@@ -39,6 +39,10 @@ export function InputBar({ mode, ready = true, busy = false, routeFallback = fal
|
|
|
39
39
|
const hints = taskSessionDetailInputHints(terminalWidth, taskSessionDetailHasNative, taskSessionDetailCanFork);
|
|
40
40
|
return (_jsxs(InputRail, { terminalWidth: terminalWidth, textWidth: displayWidth(`${hints.label}${hints.detail}`), fill: fillRail, children: [_jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.accent, bold: true, children: hints.label }), hints.detail ? _jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.muted, children: hints.detail }) : null] }));
|
|
41
41
|
}
|
|
42
|
+
if (mainConversationSessions) {
|
|
43
|
+
const hints = mainConversationSessionsInputHints(terminalWidth);
|
|
44
|
+
return (_jsxs(InputRail, { terminalWidth: terminalWidth, textWidth: displayWidth(`${hints.label}${hints.detail}`), fill: fillRail, children: [_jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.accent, bold: true, children: hints.label }), hints.detail ? _jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.muted, children: hints.detail }) : null] }));
|
|
45
|
+
}
|
|
42
46
|
const hints = taskSessionsInputHints(terminalWidth, taskSessionsIncludeArchived);
|
|
43
47
|
return (_jsxs(InputRail, { terminalWidth: terminalWidth, textWidth: displayWidth(`${hints.label}${hints.detail}`), fill: fillRail, children: [_jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.accent, bold: true, children: hints.label }), hints.detail ? _jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.muted, children: hints.detail }) : null] }));
|
|
44
48
|
}
|
|
@@ -564,9 +568,13 @@ function collaborationDetailInputHints(width) {
|
|
|
564
568
|
function taskSessionsInputHints(width, includeArchived) {
|
|
565
569
|
const archivedAction = includeArchived ? "H hide archived" : "H archived";
|
|
566
570
|
return selectInputHints(width, [
|
|
567
|
-
{ label: "sessions", detail: ` · Up/Dn select · Enter restore · I inspect · R rename · A archive · D delete · E export · ${archivedAction} · Esc back` },
|
|
568
|
-
{ label: "sessions", detail: " · Up/Dn select · Enter restore · I inspect · R rename · A archive · D delete · E export · Esc back" },
|
|
569
|
-
{ label: "sessions", detail: " · Up/Dn select · Enter restore · I inspect · R rename · A archive · D delete · Esc back" },
|
|
571
|
+
{ label: "sessions", detail: ` · Up/Dn select · Enter restore · C conversations · I inspect · R rename · A archive · D delete · E export · ${archivedAction} · Esc back` },
|
|
572
|
+
{ label: "sessions", detail: " · Up/Dn select · Enter restore · C conversations · I inspect · R rename · A archive · D delete · E export · Esc back" },
|
|
573
|
+
{ label: "sessions", detail: " · Up/Dn select · Enter restore · C chats · I inspect · R rename · A archive · D delete · Esc back" },
|
|
574
|
+
{ label: "sessions", detail: " · Up/Dn select · Enter restore · C chats · I inspect · R rename · A archive · Esc back" },
|
|
575
|
+
{ label: "sessions", detail: " · Up/Dn select · Enter restore · C chats · I inspect · R rename · Esc back" },
|
|
576
|
+
{ label: "sessions", detail: " · Up/Dn select · Enter restore · C chats · R rename · Esc back" },
|
|
577
|
+
{ label: "sessions", detail: " · Up/Dn select · Enter restore · C chats · Esc back" },
|
|
570
578
|
{ label: "sessions", detail: " · Up/Dn select · Enter restore · I inspect · R rename · A archive · Esc back" },
|
|
571
579
|
{ label: "sessions", detail: " · Up/Dn select · Enter restore · I inspect · R rename · Esc back" },
|
|
572
580
|
{ label: "sessions", detail: " · Up/Dn select · Enter restore · R rename · Esc back" },
|
|
@@ -580,6 +588,18 @@ function taskSessionsInputHints(width, includeArchived) {
|
|
|
580
588
|
{ label: "s", detail: "" }
|
|
581
589
|
]);
|
|
582
590
|
}
|
|
591
|
+
function mainConversationSessionsInputHints(width) {
|
|
592
|
+
return selectInputHints(width, [
|
|
593
|
+
{ label: "conversations", detail: " · Up/Dn select · Enter restore · N new · T tasks · Esc back" },
|
|
594
|
+
{ label: "conversations", detail: " · Up/Dn select · Enter restore · T tasks · Esc back" },
|
|
595
|
+
{ label: "conversations", detail: " · Up/Dn select · Enter restore · Esc back" },
|
|
596
|
+
{ label: "conversations", detail: " · Up/Dn select · Esc back" },
|
|
597
|
+
{ label: "conversations", detail: " · Esc back" },
|
|
598
|
+
{ label: "Esc back", detail: "" },
|
|
599
|
+
{ label: "chats", detail: "" },
|
|
600
|
+
{ label: "c", detail: "" }
|
|
601
|
+
]);
|
|
602
|
+
}
|
|
583
603
|
function taskSessionDetailInputHints(width, hasNative, canFork) {
|
|
584
604
|
const nativeActions = hasNative
|
|
585
605
|
? `${canFork ? "C continue · B branch" : "C continue"} · `
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { compactEndByDisplayWidth, displayWidth } from "./display-width.js";
|
|
4
|
+
import { TUI_THEME } from "./theme.js";
|
|
5
|
+
export function MainConversationsView({ conversations, selectedIndex, notice = null, loading = false, error = null, height = 20, terminalWidth = process.stdout.columns || 120 }) {
|
|
6
|
+
const viewportHeight = Math.max(1, height);
|
|
7
|
+
const width = mainConversationsContentWidth(terminalWidth);
|
|
8
|
+
const lines = mainConversationsDisplayLines(conversations, selectedIndex, viewportHeight, terminalWidth, { notice, loading, error });
|
|
9
|
+
const blankRows = Math.max(0, viewportHeight - lines.length);
|
|
10
|
+
return (_jsxs(Box, { flexDirection: "column", height: viewportHeight, children: [lines.map((line, index) => (_jsx(MainConversationRow, { line: line, width: width }, `${line.conversationIndex ?? line.tone}-${index}`))), Array.from({ length: blankRows }, (_, index) => (_jsx(Text, { backgroundColor: TUI_THEME.surface, children: " ".repeat(width) }, `main-conversation-fill-${index}`)))] }));
|
|
11
|
+
}
|
|
12
|
+
export function mainConversationsDisplayLines(conversations, selectedIndex, height, terminalWidth, state = {}) {
|
|
13
|
+
const viewportHeight = Math.max(1, Math.trunc(height));
|
|
14
|
+
const width = mainConversationsContentWidth(terminalWidth);
|
|
15
|
+
const lines = [{
|
|
16
|
+
text: fitMainConversationCandidates(["Main conversations", "Conversations", "Chats", "C"], width),
|
|
17
|
+
tone: "heading"
|
|
18
|
+
}];
|
|
19
|
+
if (viewportHeight >= 3) {
|
|
20
|
+
const messages = conversations.reduce((sum, conversation) => sum + conversation.messageCount, 0);
|
|
21
|
+
const nativeSessions = conversations.reduce((sum, conversation) => sum + conversation.nativeSessionCount, 0);
|
|
22
|
+
lines.push({
|
|
23
|
+
text: fitMainConversationCandidates([
|
|
24
|
+
`${conversations.length} ${conversations.length === 1 ? "conversation" : "conversations"} · ${messages} messages · ${nativeSessions} native`,
|
|
25
|
+
`${conversations.length} conversations · ${messages} messages`,
|
|
26
|
+
`${conversations.length} conversations`,
|
|
27
|
+
`${conversations.length} chats`
|
|
28
|
+
], width),
|
|
29
|
+
tone: "muted"
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
if (viewportHeight >= 4 && state.notice) {
|
|
33
|
+
lines.push({ text: fitMainConversationText(state.notice, width), tone: "success" });
|
|
34
|
+
}
|
|
35
|
+
const slots = Math.max(0, viewportHeight - lines.length);
|
|
36
|
+
if (state.loading) {
|
|
37
|
+
if (slots > 0) {
|
|
38
|
+
lines.push({ text: fitMainConversationText("loading Main conversations", width), tone: "muted" });
|
|
39
|
+
}
|
|
40
|
+
return lines;
|
|
41
|
+
}
|
|
42
|
+
if (state.error) {
|
|
43
|
+
if (slots > 0) {
|
|
44
|
+
lines.push({ text: fitMainConversationText(`error · ${safeMainConversationText(state.error)}`, width), tone: "danger" });
|
|
45
|
+
}
|
|
46
|
+
return lines;
|
|
47
|
+
}
|
|
48
|
+
if (conversations.length === 0) {
|
|
49
|
+
if (slots > 0) {
|
|
50
|
+
lines.push({ text: fitMainConversationText("No saved Main conversations", width), tone: "muted" });
|
|
51
|
+
}
|
|
52
|
+
return lines;
|
|
53
|
+
}
|
|
54
|
+
const selected = clampMainConversationIndex(selectedIndex, conversations.length);
|
|
55
|
+
const visibleCount = Math.min(slots, conversations.length);
|
|
56
|
+
const start = mainConversationWindowStart(selected, conversations.length, visibleCount);
|
|
57
|
+
for (let index = start; index < start + visibleCount; index += 1) {
|
|
58
|
+
const conversation = conversations[index];
|
|
59
|
+
if (!conversation) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
lines.push({
|
|
63
|
+
text: mainConversationRowText(conversation, index === selected, width),
|
|
64
|
+
tone: conversation.current ? "success" : index === selected ? "active" : "muted",
|
|
65
|
+
conversationIndex: index
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return lines;
|
|
69
|
+
}
|
|
70
|
+
export function moveMainConversationSelection(current, delta, conversationCount, wrap = false) {
|
|
71
|
+
if (conversationCount <= 0) {
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
const normalizedCurrent = clampMainConversationIndex(current, conversationCount);
|
|
75
|
+
const next = normalizedCurrent + Math.trunc(delta);
|
|
76
|
+
if (wrap) {
|
|
77
|
+
return ((next % conversationCount) + conversationCount) % conversationCount;
|
|
78
|
+
}
|
|
79
|
+
return Math.min(conversationCount - 1, Math.max(0, next));
|
|
80
|
+
}
|
|
81
|
+
function MainConversationRow({ line, width }) {
|
|
82
|
+
const trailingWidth = Math.max(0, width - displayWidth(line.text));
|
|
83
|
+
const theme = mainConversationLineTheme(line.tone);
|
|
84
|
+
return (_jsxs(Text, { children: [_jsx(Text, { ...theme, children: line.text }), trailingWidth > 0 ? _jsx(Text, { backgroundColor: TUI_THEME.surface, children: " ".repeat(trailingWidth) }) : null] }));
|
|
85
|
+
}
|
|
86
|
+
function mainConversationRowText(conversation, selected, width) {
|
|
87
|
+
const marker = `${selected ? ">" : " "} ${conversation.current ? "*" : " "} `;
|
|
88
|
+
const title = safeMainConversationText(conversation.title);
|
|
89
|
+
const messages = `${conversation.messageCount} ${conversation.messageCount === 1 ? "message" : "messages"}`;
|
|
90
|
+
const native = `${conversation.nativeSessionCount} native`;
|
|
91
|
+
const date = conversation.lastActivityAt.slice(5, 16).replace("T", " ");
|
|
92
|
+
const scope = conversation.id
|
|
93
|
+
? `#${conversation.id.replace(/^conversation-/, "")}`
|
|
94
|
+
: "legacy";
|
|
95
|
+
return fitMainConversationCandidates([
|
|
96
|
+
[marker + title, messages, native, date].join(" · "),
|
|
97
|
+
[marker + title, messages, native].join(" · "),
|
|
98
|
+
[marker + title, messages].join(" · "),
|
|
99
|
+
[marker + title, date].join(" · "),
|
|
100
|
+
[marker + scope, messages].join(" · "),
|
|
101
|
+
marker.trimEnd()
|
|
102
|
+
], width);
|
|
103
|
+
}
|
|
104
|
+
function mainConversationWindowStart(selected, count, visibleCount) {
|
|
105
|
+
if (visibleCount <= 0 || count <= visibleCount) {
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
return Math.min(count - visibleCount, Math.max(0, selected - Math.floor(visibleCount / 2)));
|
|
109
|
+
}
|
|
110
|
+
function clampMainConversationIndex(index, count) {
|
|
111
|
+
return Math.min(Math.max(0, count - 1), Math.max(0, Math.trunc(index)));
|
|
112
|
+
}
|
|
113
|
+
function fitMainConversationCandidates(candidates, width) {
|
|
114
|
+
return candidates.find((candidate) => displayWidth(candidate) <= width)
|
|
115
|
+
?? fitMainConversationText(candidates.at(-1) ?? "", width);
|
|
116
|
+
}
|
|
117
|
+
function fitMainConversationText(text, width) {
|
|
118
|
+
return compactEndByDisplayWidth(text, Math.max(1, width));
|
|
119
|
+
}
|
|
120
|
+
function safeMainConversationText(text) {
|
|
121
|
+
return text
|
|
122
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
123
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
124
|
+
.replace(/\s+/g, " ")
|
|
125
|
+
.trim();
|
|
126
|
+
}
|
|
127
|
+
function mainConversationLineTheme(tone) {
|
|
128
|
+
return {
|
|
129
|
+
backgroundColor: TUI_THEME.surface,
|
|
130
|
+
color: tone === "heading" || tone === "active"
|
|
131
|
+
? TUI_THEME.accent
|
|
132
|
+
: tone === "success"
|
|
133
|
+
? TUI_THEME.success
|
|
134
|
+
: tone === "danger"
|
|
135
|
+
? TUI_THEME.danger
|
|
136
|
+
: TUI_THEME.muted,
|
|
137
|
+
...(tone === "heading" || tone === "danger" ? { bold: true } : {})
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function mainConversationsContentWidth(terminalWidth) {
|
|
141
|
+
return Math.max(1, terminalWidth - 2);
|
|
142
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = "0.2.
|
|
1
|
+
export const version = "0.2.9";
|