Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quick-threads-page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"t3code-cli": minor
---

page thread transcripts with upstream user-turn cursors
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,14 @@ t3cli list [--project <ref>] [--archived | --all]
t3cli search <query> [--limit <1-50>] # Search conversation content
t3cli show [--thread <id>] # Show thread details
t3cli send [--thread <id>] [message] # Send message to thread
t3cli transcript [--thread <id>] [--limit] # View messages
t3cli transcript [--thread <id>] [--turn-limit N] [--before-cursor <cursor>] [--all] # View messages
t3cli wait [--thread <id>] # Wait for completion
```

`transcript` loads the latest 10 user turns by default and includes pagination metadata in JSON
output. Pass the returned `page.beforeCursor` to `--before-cursor` for the next older page, use
`--turn-limit` to set the page size, or use `--all` to load the complete transcript.

### Advanced Thread Commands

```sh
Expand Down
9 changes: 8 additions & 1 deletion skills/t3code-cli/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,17 @@ t3cli send [--thread <id>] [--force|-f] [message] [--stdin]
[--wait] [--format auto|human|json|ndjson]

t3cli show [--thread <id>] [--format auto|human|json]
t3cli transcript [--thread <id>] [--limit N] [--full] [--format json]
t3cli transcript [--thread <id>] [--limit N]
[--turn-limit N] [--before-cursor <cursor>] [--all]
[--full] [--format auto|human|json]
t3cli wait [--thread <id>] [--format auto|human|ndjson]
```

`transcript` loads the latest 10 user turns by default. Older-page requests default to 20 turns.
JSON output includes `page.beforeCursor` and `page.hasMore`; pass the cursor to `--before-cursor` to
load the next older page. `--turn-limit` sets either page size. `--all` loads the full thread and
cannot be combined with the paging flags. `--limit` only caps messages rendered in human output.

## terminal

Thread scope uses `--thread` or `T3CODE_THREAD_ID`. Terminal ids remain positional arguments.
Expand Down
4 changes: 2 additions & 2 deletions src/application/error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { DomainError } from "../domain/error.ts";
import type { RpcError } from "../rpc/error.ts";
import type { OrchestrationError } from "../orchestration/service.ts";

export type ApplicationError = DomainError | RpcError;
export type ApplicationError = DomainError | OrchestrationError;
11 changes: 9 additions & 2 deletions src/application/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type {
OrchestrationSearchThreadsInput,
OrchestrationShellSnapshot,
OrchestrationThread,
OrchestrationThreadDetailSnapshot,
OrchestrationThreadDetailWindow,
OrchestrationThreadShell,
ProjectScript,
ProjectScriptIcon,
Expand Down Expand Up @@ -69,6 +71,11 @@ export interface SnoozeThreadInput {

export type ListThreadsInclude = "active" | "archived" | "all";

export interface GetThreadMessagesInput {
readonly threadId: string;
readonly window?: OrchestrationThreadDetailWindow;
}

export type UpdateThreadInput = {
readonly threadId: string;
readonly title?: string;
Expand Down Expand Up @@ -218,8 +225,8 @@ export type T3ThreadApplicationService = {
ApplicationError
>;
readonly getThreadMessages: (
threadId: string,
) => Effect.Effect<OrchestrationThread, ApplicationError>;
input: GetThreadMessagesInput,
) => Effect.Effect<OrchestrationThreadDetailSnapshot, ApplicationError>;
readonly showThread: (threadId: string) => Effect.Effect<ThreadShow, ApplicationError>;
readonly approveThread: (input: {
readonly threadId: string;
Expand Down
5 changes: 3 additions & 2 deletions src/application/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { T3Orchestration } from "../orchestration/service.ts";
import { ProjectLookupError, ThreadLookupError, ThreadSessionError } from "../domain/error.ts";
import { resolveProjectScope } from "../domain/helpers.ts";
import {
type GetThreadMessagesInput,
type ListThreadsInclude,
type SnoozeThreadInput,
type StartThreadInput,
Expand Down Expand Up @@ -112,9 +113,9 @@ export const makeThreadApplication = Effect.fn("makeThreadApplication")(function
});
});
const getThreadMessages = Effect.fn("T3ApplicationLive.getThreadMessages")(function* (
threadId: string,
input: GetThreadMessagesInput,
) {
return yield* orchestration.getThreadSnapshot(threadId);
return yield* orchestration.getThreadDetailSnapshot(input);
});
const showThread = Effect.fn("T3ApplicationLive.showThread")(function* (threadId: string) {
const thread = yield* orchestration.getThreadSnapshot(threadId);
Expand Down
32 changes: 26 additions & 6 deletions src/cli/format/thread.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { ThreadSearchResult, ThreadShow } from "../../application/threads.ts";
import type { WaitEvent } from "../../application/service.ts";
import type { OrchestrationThread, OrchestrationThreadShell } from "@t3tools/contracts";
import type {
OrchestrationThread,
OrchestrationThreadDetailSnapshot,
OrchestrationThreadShell,
} from "@t3tools/contracts";
import { latestAssistantMessage, threadStatus } from "../../domain/thread-lifecycle.ts";
import { formatChatTranscript, formatRecord, formatTable } from "./human.ts";

Expand Down Expand Up @@ -139,9 +143,15 @@ export function formatThreadStartedHuman(input: {
])}`;
}

export function formatThreadMessagesHuman(thread: OrchestrationThread, limit: number) {
const messages = limit === 0 ? thread.messages : thread.messages.slice(-limit);
return formatChatTranscript(messages);
export function formatThreadMessagesHuman(
snapshot: OrchestrationThreadDetailSnapshot,
limit: number,
) {
const messages = limit === 0 ? snapshot.thread.messages : snapshot.thread.messages.slice(-limit);
const transcript = formatChatTranscript(messages);
return snapshot.page?.hasMore === true && snapshot.page.beforeCursor !== null
? `${transcript}\nearlier turns available\nbefore cursor: ${snapshot.page.beforeCursor}\n`
: transcript;
}

export function formatWaitDoneHuman(thread: OrchestrationThread) {
Expand All @@ -151,8 +161,18 @@ export function formatWaitDoneHuman(thread: OrchestrationThread) {
}`;
}

export function formatThreadMessagesJson(thread: OrchestrationThread, full: boolean) {
return full ? thread : { thread: stripThreadMessages(thread), messages: thread.messages };
export function formatThreadMessagesJson(
snapshot: OrchestrationThreadDetailSnapshot,
full: boolean,
) {
return full
? { ...snapshot, page: snapshot.page ?? null }
: {
snapshotSequence: snapshot.snapshotSequence,
thread: stripThreadMessages(snapshot.thread),
messages: snapshot.thread.messages,
page: snapshot.page ?? null,
};
}

export function formatWaitEventNdjson(event: WaitEvent) {
Expand Down
36 changes: 33 additions & 3 deletions src/cli/threads/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Command, Flag } from "effect/unstable/cli";

import { extraArgsConfig } from "../extra-args.ts";
import { formatFlag, threadFlag } from "../flags.ts";
import { InvalidLimitError } from "../error.ts";
import { InvalidFlagCombinationError, InvalidLimitError } from "../error.ts";
import { MissingThreadError } from "../error.ts";
import { resolveThreadId } from "../scope/index.ts";
import { formatThreadMessagesHuman, formatThreadMessagesJson } from "../format/thread.ts";
Expand All @@ -19,17 +19,37 @@ export const getThreadTranscriptCommand = Command.make(
{
thread: threadFlag,
limit: Flag.integer("limit").pipe(Flag.withDefault(20)),
turnLimit: Flag.integer("turn-limit").pipe(Flag.optional),
beforeCursor: Flag.string("before-cursor").pipe(Flag.optional),
all: Flag.boolean("all"),
full: Flag.boolean("full"),
format: formatFlag,
...extraArgsConfig,
},
({ thread, limit, full, format }) =>
({ thread, limit, turnLimit, beforeCursor, all, full, format }) =>
Effect.gen(function* () {
if (limit < 0) {
return yield* Effect.fail(
new InvalidLimitError({ message: `invalid limit: ${limit}`, value: String(limit) }),
);
}
const turnLimitValue = Option.getOrUndefined(turnLimit);
const beforeCursorValue = Option.getOrUndefined(beforeCursor);
if (turnLimitValue !== undefined && turnLimitValue <= 0) {
return yield* Effect.fail(
new InvalidLimitError({
message: `invalid turn limit: ${turnLimitValue}`,
value: String(turnLimitValue),
}),
);
}
if (all && (turnLimitValue !== undefined || beforeCursorValue !== undefined)) {
return yield* Effect.fail(
new InvalidFlagCombinationError({
message: "--all cannot be combined with --turn-limit or --before-cursor",
}),
);
}
const application = yield* T3Application;
const cliRuntime = yield* CliRuntime;
const t3CliEnv = yield* loadT3CliEnv;
Expand All @@ -46,7 +66,17 @@ export const getThreadTranscriptCommand = Command.make(
);
}
const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json");
const detail = yield* application.getThreadMessages(threadId);
const detail = yield* application.getThreadMessages({
threadId,
...(!all
? {
window: {
turnLimit: turnLimitValue ?? (beforeCursorValue === undefined ? 10 : 20),
...(beforeCursorValue !== undefined ? { beforeCursor: beforeCursorValue } : {}),
},
}
: {}),
});
if (resolvedFormat === "json") {
return yield* output.printJson(formatThreadMessagesJson(detail, full));
}
Expand Down
14 changes: 12 additions & 2 deletions src/connection/prepared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,20 @@ import { T3CodeConnectionError } from "./error.ts";
import { T3CodeConnectionProvider } from "./service.ts";
import type { T3CodeConnection } from "./type.ts";

export interface T3PreparedConnection extends PreparedConnection {
readonly httpAuthorization: {
readonly _tag: "Bearer";
readonly token: string;
};
}

export class T3PreparedConnectionProvider extends Context.Service<
T3PreparedConnectionProvider,
{
readonly get: Effect.Effect<PreparedConnection, ConnectionAttemptError | T3CodeConnectionError>;
readonly get: Effect.Effect<
T3PreparedConnection,
ConnectionAttemptError | T3CodeConnectionError
>;
}
>()("t3cli/T3PreparedConnectionProvider") {}

Expand Down Expand Up @@ -59,7 +69,7 @@ const makePreparedConnection = Effect.fn("makePreparedConnection")(function* (
label: descriptor.label,
connectionId: descriptor.environmentId,
}),
} satisfies PreparedConnection;
} satisfies T3PreparedConnection;
});

const makeT3PreparedConnectionProvider = Effect.fn("makeT3PreparedConnectionProvider")(
Expand Down
10 changes: 10 additions & 0 deletions src/orchestration/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as Schema from "effect/Schema";

export class ThreadSnapshotRequestError extends Schema.TaggedErrorClass<ThreadSnapshotRequestError>()(
"ThreadSnapshotRequestError",
{
message: Schema.String,
threadId: Schema.String,
cause: Schema.Defect(),
},
) {}
1 change: 1 addition & 0 deletions src/orchestration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export {
type Orchestration,
type OrchestrationError,
} from "./service.ts";
export { ThreadSnapshotRequestError } from "./error.ts";
export { makeT3Orchestration, T3OrchestrationLive } from "./layer.ts";
72 changes: 72 additions & 0 deletions src/orchestration/layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import { HttpClient } from "effect/unstable/http";
import {
ORCHESTRATION_WS_METHODS,
ThreadId,
Expand All @@ -12,14 +13,25 @@ import {
type OrchestrationShellStreamItem,
type OrchestrationThreadStreamItem,
} from "@t3tools/contracts";
import { environmentEndpointUrl } from "@t3tools/client-runtime/environment";
import {
executeEnvironmentHttpRequest,
makeEnvironmentHttpApiClient,
} from "@t3tools/client-runtime/rpc";
import { applyShellStreamEvent } from "@t3tools/client-runtime/state/shell";

import { T3PreparedConnectionProvider } from "../connection/prepared.ts";
import { RpcError } from "../rpc/error.ts";
import { T3RpcOperations } from "../rpc/operation.ts";
import { ThreadSnapshotRequestError } from "./error.ts";
import { T3Orchestration, type OpenThread, type Orchestration } from "./service.ts";

const THREAD_SNAPSHOT_TIMEOUT_MS = 30_000;

export const makeT3Orchestration = Effect.fn("makeT3Orchestration")(function* () {
const rpc = yield* T3RpcOperations;
const preparedConnectionProvider = yield* T3PreparedConnectionProvider;
const httpClient = yield* HttpClient.HttpClient;

const watchShellSnapshots: Orchestration["watchShellSnapshots"] = () =>
rpc
Expand Down Expand Up @@ -133,6 +145,65 @@ export const makeT3Orchestration = Effect.fn("makeT3Orchestration")(function* ()
}
return value.snapshot.thread;
});
const getThreadDetailSnapshot: Orchestration["getThreadDetailSnapshot"] = Effect.fn(
"T3OrchestrationLive.getThreadDetailSnapshot",
)(function* (input) {
const paginationSupported =
input.window === undefined
? true
: (yield* getServerConfig()).threadSnapshotPagination === true;
const window = paginationSupported ? input.window : undefined;
const prepared = yield* preparedConnectionProvider.get.pipe(
Effect.mapError(
(cause) =>
new ThreadSnapshotRequestError({
message: "failed to prepare the thread snapshot request",
threadId: input.threadId,
cause,
}),
),
);
const threadId = ThreadId.make(input.threadId);
const requestUrl = environmentEndpointUrl(
prepared.httpBaseUrl,
`/api/orchestration/threads/${threadId}`,
);
const client = yield* makeEnvironmentHttpApiClient(prepared.httpBaseUrl).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
Effect.mapError(
(cause) =>
new ThreadSnapshotRequestError({
message: "failed to create the thread snapshot client",
threadId: input.threadId,
cause,
}),
),
);
return yield* executeEnvironmentHttpRequest(
requestUrl,
THREAD_SNAPSHOT_TIMEOUT_MS,
client.orchestration.threadSnapshot({
params: { threadId },
payload: {
...(window !== undefined ? { turnLimit: window.turnLimit } : {}),
...(window?.beforeCursor !== undefined ? { beforeCursor: window.beforeCursor } : {}),
},
headers: {
authorization: `Bearer ${prepared.httpAuthorization.token}`,
},
}),
).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
Effect.mapError(
(cause) =>
new ThreadSnapshotRequestError({
message: "failed to load the thread snapshot",
threadId: input.threadId,
cause,
}),
),
);
});
const openThread = Effect.fn("T3OrchestrationLive.openThread")(function* (threadId: string) {
return yield* watchThreadItems(threadId).pipe(
Stream.peel(Sink.head<OrchestrationThreadStreamItem>()),
Expand Down Expand Up @@ -167,6 +238,7 @@ export const makeT3Orchestration = Effect.fn("makeT3Orchestration")(function* ()
getArchivedShellSnapshot,
searchThreads,
getThreadSnapshot,
getThreadDetailSnapshot,
watchShellSnapshots,
watchShellSequence,
watchThreadItems,
Expand Down
Loading
Loading