feat(cli): add saved chat commands

This commit is contained in:
Siddharth Ganesan
2026-08-08 12:21:08 -07:00
parent 8142bf46ae
commit 3f0c1fcce0
7 changed files with 350 additions and 19 deletions
+15 -4
View File
@@ -101,16 +101,20 @@ Settings → API keys.
## Commands
Plural resource names are canonical, but every plural top-level resource group
also accepts its singular form: for example, `sim table list`,
Plural resource names are canonical, but most plural top-level resource groups
also accept their singular form: for example, `sim table list`,
`sim file download`, and `sim workflow get` are equivalent to their plural
spellings.
spellings. Chats deliberately keep separate names: `sim chat` is the terminal
conversation, while `sim chats` manages saved chat resources.
`knowledge` also accepts the shorter `kb` alias.
```bash
sim chat [prompt...] [-f <path>...] [--read-only]
sim chat -p [prompt...] [-f <path>...] [--read-only]
sim chats list [--search <text>] [--limit <n>]
sim chats get <chatId> [--read-only]
sim chats rename <chatId> --title <title>
sim workflows ls [path] [--search <text>] [--limit <n>]
sim workflows list [--folder <path>] [--deployed-only] [--limit <n>]
@@ -176,6 +180,8 @@ sim billing logs [--period 7d] [--source sim-chat] [--limit <n>] [--all-workspac
The `sim-chat` billing source combines Copilot and workspace chat usage.
Organization audit logs require a personal API key. Commands with
`--all-workspaces` otherwise default to the workspace in the active profile.
Saved chat commands also require a personal API key and use that active
workspace unless `--workspace` overrides it.
`workflows runs get` is the lightweight status and polling resource.
`--workflow` names the parent resource, while the run ID remains positional.
@@ -230,7 +236,7 @@ a searchable picker. Selecting one restores its transcript and continues it
with a fresh opaque token. The header shows the active chat title and keeps the
`/chats` switch hint visible; a new chat's generated title appears there as soon
as the server publishes it. `/rename <title>` retitles the active synced chat in
both the terminal and Sim Home. `/clear` clears the visible transcript and
both the terminal and Sim Home. `/new` clears the visible transcript and
starts a new conversation, `/help` lists commands, and `/exit` or Ctrl+D exits.
Ctrl+C clears idle input or cancels the active generation and returns to the
prompt.
@@ -254,11 +260,16 @@ pipelines and redirected output must use `-p`.
```bash
sim chat -p "Which workflows handle support tickets?"
sim chat -p --chat <chatId> "Continue this conversation"
cat incident.txt | sim chat -p "Which workflow is most likely involved?"
sim chat -p < question.txt
sim chat -p --file report.pdf "Summarize this in workspace context"
```
Pass `--chat <chatId>` to append one print-mode turn to an existing inactive
chat, print its answer, and exit. The chat must belong to the active workspace,
and synchronized history requires a personal API key.
When both a positional prompt and stdin are present, the positional prompt comes
first and the piped content follows on the next line. This matches Claude Code's
print-mode input behavior. Combined input is limited to 10 MiB of UTF-8 text.
@@ -185,10 +185,10 @@ export function contextSpans(
/** Composer slash commands, the source for the `/` menu. */
export const SLASH_COMMANDS: SuggestionItem[] = [
{
id: 'clear',
value: '/clear',
displayText: '/clear',
description: 'start a new conversation',
id: 'new',
value: '/new',
displayText: '/new',
description: 'start a new chat',
tag: 'command',
},
{
@@ -280,6 +280,166 @@ describe('chat print mode', () => {
expect(writeOutput).toHaveBeenCalledWith('Hello world')
})
it('resumes an existing chat by ID for one print-mode turn', async () => {
mocks.request.mockResolvedValueOnce({
data: {
id: 'chat-1',
title: 'Existing chat',
messages: [],
continuationToken: 'resume-token',
active: false,
},
})
mocks.requestRaw.mockResolvedValue(completed('Continued answer', 'next-token'))
const writeOutput = vi.fn()
await program(async () => '', writeOutput).parseAsync([
'node',
'sim',
'chat',
'-p',
'--chat',
'chat-1',
'Continue here',
])
expect(mocks.request).toHaveBeenCalledWith('/api/v2/chats/chat-1', {
query: { workspaceId: 'ws_local' },
auth: 'optional',
})
expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({
workspaceId: 'ws_local',
prompt: 'Continue here',
continuationToken: 'resume-token',
})
expect(writeOutput).toHaveBeenCalledWith('Continued answer')
})
it('binds a resumed print-mode token to read-only mode', async () => {
mocks.request.mockResolvedValueOnce({
data: {
id: 'chat-1',
title: 'Existing chat',
messages: [],
continuationToken: 'read-only-token',
active: false,
},
})
mocks.requestRaw.mockResolvedValue(completed('Read-only answer'))
await program(async () => '').parseAsync([
'node',
'sim',
'chat',
'-p',
'--read-only',
'--chat',
'chat-1',
'Continue safely',
])
expect(mocks.request).toHaveBeenCalledWith('/api/v2/chats/chat-1', {
query: { workspaceId: 'ws_local', readOnly: true },
auth: 'optional',
})
expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({
workspaceId: 'ws_local',
prompt: 'Continue safely',
readOnly: true,
continuationToken: 'read-only-token',
})
})
it('rejects --chat outside print mode', async () => {
await expect(
program(async () => '', vi.fn(), { isInteractive: () => true }).parseAsync([
'node',
'sim',
'chat',
'--chat',
'chat-1',
])
).rejects.toThrow('--chat can only be used with -p/--print')
expect(mocks.request).not.toHaveBeenCalled()
expect(mocks.requestRaw).not.toHaveBeenCalled()
})
it('does not race a print-mode turn into a chat active elsewhere', async () => {
mocks.request.mockResolvedValueOnce({
data: {
id: 'chat-1',
title: 'Existing chat',
messages: [],
continuationToken: 'resume-token',
active: true,
},
})
await expect(
program(async () => '').parseAsync([
'node',
'sim',
'chat',
'-p',
'--chat',
'chat-1',
'Continue here',
])
).rejects.toThrow('currently active in another client')
expect(mocks.requestRaw).not.toHaveBeenCalled()
})
it('surfaces a conflict if the chat becomes active after lookup', async () => {
mocks.request.mockResolvedValueOnce({
data: {
id: 'chat-1',
title: 'Existing chat',
messages: [],
continuationToken: 'resume-token',
active: false,
},
})
mocks.requestRaw.mockRejectedValueOnce(
new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT')
)
const writeOutput = vi.fn()
await expect(
program(async () => '', writeOutput).parseAsync([
'node',
'sim',
'chat',
'-p',
'--chat',
'chat-1',
'Continue here',
])
).rejects.toThrow('A response is already in progress for this chat')
expect(mocks.requestRaw).toHaveBeenCalledOnce()
expect(writeOutput).not.toHaveBeenCalled()
})
it('surfaces an inaccessible chat without starting a new one', async () => {
mocks.request.mockRejectedValueOnce(new SimApiError('Chat not found', 404, 'NOT_FOUND'))
await expect(
program(async () => '').parseAsync([
'node',
'sim',
'chat',
'-p',
'--chat',
'missing-chat',
'Continue here',
])
).rejects.toThrow('Chat not found')
expect(mocks.requestRaw).not.toHaveBeenCalled()
})
it('keeps the profile shorthand distinct from chat -p', async () => {
mocks.requestRaw.mockResolvedValue(completed('answer'))
@@ -1052,7 +1212,7 @@ describe('interactive chat', () => {
})
})
it('visibly resets the transcript and continuation identity with /clear', async () => {
it('visibly resets the transcript and continuation identity with /new', async () => {
mocks.requestRaw
.mockResolvedValueOnce(
sse([
@@ -1062,7 +1222,7 @@ describe('interactive chat', () => {
)
.mockResolvedValueOnce(completed('Second', 'token-2'))
const terminal = new FakeTerminal([
{ kind: 'line', value: '/clear' },
{ kind: 'line', value: '/new' },
{ kind: 'line', value: 'Fresh question' },
{ kind: 'line', value: '/exit' },
])
@@ -1998,7 +2158,7 @@ describe('interactive chat', () => {
it('does not move queued prompts into another conversation', async () => {
const terminal = new FakeTerminal([
{ kind: 'line', value: 'first' },
{ kind: 'line', value: '/clear', queued: true, display: '/clear' },
{ kind: 'line', value: '/new', queued: true, display: '/new' },
{ kind: 'line', value: 'second', queued: true, display: 'second' },
{ kind: 'line', value: '/chats', queued: true, display: '/chats' },
{ kind: 'line', value: 'third', queued: true, display: 'third' },
+41 -8
View File
@@ -332,7 +332,8 @@ async function runOneShot(
prompt: string,
attachments: ChatAttachment[],
readOnly: boolean,
dependencies: ChatDependencies
dependencies: ChatDependencies,
continuationToken?: string
): Promise<void> {
const controller = new AbortController()
const cancel = () => controller.abort()
@@ -345,6 +346,7 @@ async function runOneShot(
workspaceId,
prompt,
...(readOnly ? { readOnly: true } : {}),
...(continuationToken ? { continuationToken } : {}),
...(attachments.length ? { attachments } : {}),
},
controller.signal
@@ -373,7 +375,7 @@ type UserTurnResult =
pastes?: ReadonlyMap<number, string>
contexts?: ChatContext[]
}
| { kind: 'clear'; attachments: ChatAttachment[] }
| { kind: 'new'; attachments: ChatAttachment[] }
| { kind: 'chats'; attachments: ChatAttachment[] }
| { kind: 'rename'; title: string; attachments: ChatAttachment[] }
| { kind: 'idle'; attachments: ChatAttachment[] }
@@ -385,7 +387,7 @@ function explainInteractiveCommands(terminal: ChatTerminal): void {
'Commands:',
' ctrl+v attach the clipboard image or file (or cmd+v on macOS)',
' <file path> drop or type a path to attach the file',
' /clear start a new conversation',
' /new start a new chat',
' /chats view and switch chats',
' /rename <title> rename the active chat',
' /help show this help',
@@ -466,7 +468,7 @@ async function readUserTurn(
explainInteractiveCommands(terminal)
continue
}
if (trimmed === '/clear') return { kind: 'clear', attachments }
if (trimmed === '/new') return { kind: 'new', attachments }
if (trimmed === '/chats') {
return { kind: 'chats', attachments }
}
@@ -1013,11 +1015,11 @@ async function runInteractive(
nextPromptConflictRetries = 0
continue
}
if ((input.kind === 'clear' || input.kind === 'chats') && terminal.hasQueuedInput()) {
if ((input.kind === 'new' || input.kind === 'chats') && terminal.hasQueuedInput()) {
terminal.status('Finish queued prompts before changing conversations.')
continue
}
if (input.kind === 'clear') {
if (input.kind === 'new') {
startNewConversation()
continue
}
@@ -1470,18 +1472,30 @@ export function chatCommand(overrides: Partial<ChatDependencies> = {}): Command
.description('Ask Sim Chat about the active workspace')
.argument('[prompt...]', 'Question to ask')
.option('-p, --print', 'Print the final response and exit')
.option('--chat <chatId>', 'Resume an existing chat by ID (print mode only)')
.option('-f, --file <path>', 'Attach a local file (repeatable)', collectFile, [])
.option('--read-only', 'Restrict Sim Chat to read-only workspace tools')
.action(
async (
promptParts: string[],
options: { print?: boolean; file: string[]; readOnly?: boolean },
options: { print?: boolean; chat?: string; file: string[]; readOnly?: boolean },
command: Command
) => {
const positionalPrompt = promptParts.join(' ')
const positionalBytes = utf8Bytes(positionalPrompt)
if (positionalBytes > MAX_CHAT_PROMPT_BYTES) throw inputTooLarge()
const chatId = options.chat?.trim()
if (options.chat !== undefined && !chatId) {
throw new SimApiError('Chat ID must not be empty.', 0)
}
if (chatId && !options.print) {
throw new SimApiError(
'--chat can only be used with -p/--print. Use /chats in interactive mode.',
0
)
}
const interactive = !options.print && dependencies.isInteractive()
if (!options.print && !interactive) {
throw new SimApiError(
@@ -1515,13 +1529,32 @@ export function chatCommand(overrides: Partial<ChatDependencies> = {}): Command
)
return
}
let continuationToken: string | undefined
if (chatId) {
const chat = await loadChat(client, workspaceId, chatId, options.readOnly === true)
if (!chat.continuationToken) {
throw new SimApiError(
'Sim Chat did not return a continuation token for the selected chat.',
0
)
}
if (chat.active) {
throw new SimApiError(
'The selected chat is currently active in another client. Wait for it to finish before resuming it.',
409,
'CONFLICT'
)
}
continuationToken = chat.continuationToken
}
await runOneShot(
client,
workspaceId,
prompt,
attachments,
options.readOnly === true,
dependencies
dependencies,
continuationToken
)
}
)
+31
View File
@@ -292,6 +292,37 @@ export const CLI_CONTRACT: CliContract = {
updateCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } },
// ─── Output columns for list commands ─────────────────────────────────────
listChats: {
flags: { search: { describe: 'Filter chats by title' } },
columns: [
{ header: 'id' },
{ header: 'title' },
{ header: 'updated', path: 'updatedAt', format: 'timestamp' },
{ header: 'pinned', format: 'bool' },
{ header: 'active', format: 'bool' },
],
},
getChat: {
describe: 'Show chat metadata and message count',
flags: {
readOnly: {
boolean: true,
describe: 'Bind the returned continuation token to read-only mode',
},
},
fields: [
{ header: 'id' },
{ header: 'title' },
{ header: 'messages', format: 'count' },
{ header: 'active', format: 'bool' },
],
},
renameChat: {
command: 'chats rename',
describe: 'Rename a chat',
flags: { title: { describe: 'New chat title' } },
fields: [{ header: 'id' }, { header: 'title' }],
},
listTables: {
flags: { folderPath: FOLDER_PATH_FLAG },
columns: [
+3
View File
@@ -252,6 +252,9 @@ describe('generated operation table', () => {
'getLog',
'getBillingStatus',
'listBillingLogs',
'listChats',
'getChat',
'renameChat',
'listWorkflowRuns',
'getWorkflowRun',
'resumeWorkflow',
@@ -108,6 +108,56 @@ describe('commands parsed through commander', () => {
expect(program().commands.some((command) => command.name() === 'folders')).toBe(false)
})
it('exposes saved chats through the generated resource commands', async () => {
expect(
commandAt('chats')
.commands.map((command) => command.name())
.sort()
).toEqual(['get', 'list', 'rename'])
const listHelp = commandAt('chats', 'list').helpInformation()
expect(listHelp).toContain('--search <value>')
expect(listHelp).toContain('Filter chats by title')
expect(listHelp).toContain('--limit <n>')
const [listPath, listOptions] = await run([
'chats',
'list',
'--search',
'incident',
'--limit',
'5',
])
expect(listPath).toBe('/api/v2/chats')
expect(listOptions.query).toMatchObject({
workspaceId: 'ws_local',
search: 'incident',
limit: 5,
})
const getHelp = commandAt('chats', 'get').helpInformation()
expect(getHelp).toContain('Bind the returned continuation token to read-only mode')
const [getPath, getOptions] = await run(['chats', 'get', 'chat_1', '--read-only'])
expect(getPath).toBe('/api/v2/chats/chat_1')
expect(getOptions.query).toEqual({ workspaceId: 'ws_local', readOnly: true })
const renameHelp = commandAt('chats', 'rename').helpInformation()
expect(renameHelp).toContain('--title <value>')
expect(renameHelp).toContain('New chat title')
const [renamePath, renameOptions] = await run([
'chats',
'rename',
'chat_1',
'--title',
'Incident review',
])
expect(renamePath).toBe('/api/v2/chats/chat_1')
expect(renameOptions).toMatchObject({
method: 'PATCH',
body: { workspaceId: 'ws_local', title: 'Incident review' },
})
})
it('describes generated resource and sub-resource groups', () => {
expect(commandAt('tables').description()).toBe('Manage tables')
expect(commandAt('tables', 'rows').description()).toBe('Manage table rows')
@@ -754,6 +804,32 @@ describe('single-resource rendering', () => {
expect(JSON.parse(printed[0])).toEqual({ row: { id: 'r1' }, operation: 'inserted' })
})
it('keeps chat implementation details out of human output', async () => {
const chat = {
id: 'chat_1',
title: 'Incident review',
messages: [
{ id: 'message_1', role: 'user', content: 'Private prompt', timestamp: '2026-08-04' },
{
id: 'message_2',
role: 'assistant',
content: 'Private answer',
timestamp: '2026-08-04',
},
],
continuationToken: 'opaque-token',
active: false,
}
const human = await lines(['chats', 'get', 'chat_1'], chat, 'text')
expect(human).toEqual(['id\tchat_1', 'title\tIncident review', 'messages\t2', 'active\tno'])
expect(human.join('\n')).not.toContain('opaque-token')
expect(human.join('\n')).not.toContain('Private prompt')
const machine = await lines(['chats', 'get', 'chat_1'], chat, 'json')
expect(JSON.parse(machine[0])).toEqual(chat)
})
it('keeps sensitive run detail opt-in for human log output', async () => {
const log = {
runId: 'run_1',
@@ -850,6 +926,23 @@ describe('contract-selected list rendering', () => {
expect(printed).toEqual(['0.91\tpolicy.md\t2\tRefunds are available for 30 days.'])
})
it('formats saved chats like other resource lists', async () => {
const printed = await lines(
['chats', 'list'],
[
{
id: 'chat_1',
title: 'Incident review',
updatedAt: '2026-08-04T12:34:56.789Z',
pinned: false,
active: true,
},
]
)
expect(printed).toEqual(['chat_1\tIncident review\t2026-08-04 12:34:56\tno\tyes'])
})
it('renders row matches as rows', async () => {
const printed = await lines(['tables', 'rows', 'find', 'tbl_1', '--q', 'alice'], {
matches: [{ ordinal: 3, rowId: 'row_1', column: 'email' }],