Skip to content
Draft
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ tdc auth refresh-token view # print the stored OAuth refresh token
```bash
tdc inbox # inbox threads
tdc inbox --unread # unread threads only
tdc inbox --mentions # unread threads where you were mentioned
tdc mentions # content mentioning you
tdc mentions --since 2026-04-01 --all --json
tdc thread view <ref> # view thread with comments
Expand Down
4 changes: 3 additions & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,15 @@ Arguments:
Options:

- `--unread` - Only show unread threads
- `--mentions` - Only show unread threads where you were mentioned (implies `--unread`)
- `--since <date>` - Filter by date (ISO format)
- `--until <date>` - Filter by date
- `--limit <n>` - Max items (default: 50)
- `--json` / `--ndjson` - Machine-readable output

Output format (human-readable):

- Title, channel name, timestamp (relative), unread indicator
- Title, channel name, timestamp (relative), unread indicator, mention indicator (`@` / `(mention)`)
- URL on second line for each entry
- Content truncated in list view

Expand Down Expand Up @@ -490,6 +491,7 @@ tdc workspace use "My Team"
# View inbox
tdc inbox
tdc inbox --unread
tdc inbox --mentions

# View a thread
tdc thread view id:CbT8n2Kp4Qx6Rz9Lm3Va
Expand Down
2 changes: 2 additions & 0 deletions skills/comms-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ All target command flags pass through (e.g. `--json`, `--raw`, `--full`).
```bash
tdc inbox # Show inbox threads
tdc inbox --unread # Only unread threads
tdc inbox --mentions # Only unread threads where you were mentioned (implies --unread)
tdc inbox --archive-filter all # Show active + done threads
tdc inbox --archive-filter archived # Show only done threads
tdc inbox --channel <filter> # Filter by channel name (fuzzy)
Expand Down Expand Up @@ -461,6 +462,7 @@ tdc view https://comms.todoist.com/a/1585/msg/CbV8n2Kp4Qx6Rz9Lm3Va/m/CbS8n2Kp4Qx
**Check inbox and respond:**
```bash
tdc inbox --unread --json
tdc inbox --mentions --json # Unread threads with an unread @mention of you
tdc thread view <thread-ref> --unread
tdc thread reply <thread-ref> "Thanks, I'll look into this."
tdc thread done <thread-ref> --yes
Expand Down
14 changes: 7 additions & 7 deletions src/commands/channel/threads.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { outputIds, resolveOutputMode } from '@doist/cli-core'
import type { ArchiveFilter, Thread } from '@doist/comms-sdk'
import type { ArchiveFilter, Thread, UnreadThread } from '@doist/comms-sdk'
import chalk from 'chalk'
import { getCommsClient, getCurrentWorkspaceId } from '../../lib/api.js'
import { formatRelativeDate } from '../../lib/dates.js'
Expand All @@ -14,7 +14,7 @@ import {
} from '../../lib/output.js'
import { assertChannelIsPublic } from '../../lib/public-channels.js'
import { resolveChannelRef, resolveWorkspaceRef } from '../../lib/refs.js'
import { fetchUnreadThreadIds } from '../../lib/threads.js'
import { fetchUnreadThreads } from '../../lib/threads.js'
import { decodeCursor, encodeCursor } from './helpers.js'

type ChannelThreadsOptions = PaginatedViewOptions & {
Expand Down Expand Up @@ -80,21 +80,21 @@ export async function showChannelThreads(
const client = await getCommsClient()

const needsUnreadData = outputMode !== 'ids-only' || options.unread
const [threadsData, unreadThreadIds] = await Promise.all([
const [threadsData, unreadThreads] = await Promise.all([
client.threads.getThreads(
archived === undefined
? { workspaceId, channelId: channel.id }
: { workspaceId, channelId: channel.id, archived },
),
needsUnreadData
? fetchUnreadThreadIds(client, workspaceId)
: Promise.resolve(new Set<string>()),
? fetchUnreadThreads(client, workspaceId)
: Promise.resolve(new Map<string, UnreadThread>()),
])

let threads = threadsData

if (options.unread) {
threads = threads.filter((thread) => unreadThreadIds.has(thread.id))
threads = threads.filter((thread) => unreadThreads.has(thread.id))
}

if (sinceTs !== undefined) {
Expand Down Expand Up @@ -124,7 +124,7 @@ export async function showChannelThreads(

const decoratedPage: DecoratedThread[] = page.map((thread) => ({
...thread,
isUnread: unreadThreadIds.has(thread.id),
isUnread: unreadThreads.has(thread.id),
}))
const paginated: PaginatedOutput<DecoratedThread> = {
results: decoratedPage,
Expand Down
92 changes: 92 additions & 0 deletions src/commands/inbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,95 @@ describe('inbox API errors', () => {
).rejects.toThrow('limit must be <= 500')
})
})

describe('inbox unread mentions', () => {
const threads = [
{
id: 'thread-read',
channelId: 'CH1',
title: 'Read thread',
posted: '2026-05-03T00:00:00Z',
url: 'https://example.test/thread-read',
},
{
id: 'thread-unread',
channelId: 'CH1',
title: 'Plain unread',
posted: '2026-05-02T00:00:00Z',
url: 'https://example.test/thread-unread',
},
{
id: 'thread-mention',
channelId: 'CH1',
title: 'Mentioned thread',
posted: '2026-05-01T00:00:00Z',
url: 'https://example.test/thread-mention',
},
]
const unreadData = [
{ threadId: 'thread-unread', channelId: 'CH1', objIndex: 3, directMention: false },
{ threadId: 'thread-mention', channelId: 'CH1', objIndex: 5, directMention: true },
]
let logSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
vi.clearAllMocks()
apiMocks.getCurrentWorkspaceId.mockResolvedValue(1)
mockClient({
inboxThreads: threads,
unreadData,
getChannel: vi.fn().mockResolvedValue({ id: 'CH1', name: 'engineering' }),
})
logSpy = captureConsole('log')
})

function parsedJsonOutput(): Array<Record<string, unknown>> {
expect(logSpy).toHaveBeenCalledTimes(1)
return JSON.parse(logSpy.mock.calls[0]?.[0] as string)
}

it('derives hasUnreadMention from the getUnread directMention flag', async () => {
await createProgram().parseAsync(['node', 'tdc', 'inbox', '--json'])

const byId = new Map(parsedJsonOutput().map((t) => [t.id, t]))
expect(byId.get('thread-read')).toMatchObject({ isUnread: false, hasUnreadMention: false })
expect(byId.get('thread-unread')).toMatchObject({
isUnread: true,
hasUnreadMention: false,
})
expect(byId.get('thread-mention')).toMatchObject({
isUnread: true,
hasUnreadMention: true,
})
})

it('--mentions keeps only unread threads with a direct mention', async () => {
await createProgram().parseAsync(['node', 'tdc', 'inbox', '--mentions', '--json'])

expect(parsedJsonOutput().map((t) => t.id)).toEqual(['thread-mention'])
})

it('sorts mention threads before newer plain-unread threads within a channel', async () => {
await createProgram().parseAsync(['node', 'tdc', 'inbox', '--json'])

expect(parsedJsonOutput().map((t) => t.id)).toEqual([
'thread-mention',
'thread-unread',
'thread-read',
])
})

it('shows a mention marker next to the unread badge in human output', async () => {
vi.stubEnv('TDC_ACCESSIBLE', '0')
try {
await createProgram().parseAsync(['node', 'tdc', 'inbox'])
} finally {
vi.unstubAllEnvs()
}

const lines = logSpy.mock.calls.flat() as string[]
expect(lines).toContain(' Mentioned thread * @')
expect(lines).toContain(' Plain unread *')
expect(lines).toContain(' Read thread')
})
})
39 changes: 27 additions & 12 deletions src/commands/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ import { toDate, type PaginatedViewOptions } from '../lib/options.js'
import { colors, formatJson, formatNdjson } from '../lib/output.js'
import { getPublicChannelIds } from '../lib/public-channels.js'
import { resolveWorkspaceRef } from '../lib/refs.js'
import { fetchUnreadThreadIds } from '../lib/threads.js'
import { fetchUnreadThreads } from '../lib/threads.js'

type InboxOptions = PaginatedViewOptions & {
workspace?: string
channel?: string
unread?: boolean
mentions?: boolean
archiveFilter?: ArchiveFilter
}

Expand All @@ -42,22 +43,29 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions
const client = await getCommsClient()
const limit = options.limit ? parseInt(options.limit, 10) : 50

const [threads, unreadThreadIds] = await Promise.all([
const [threads, unreadThreads] = await Promise.all([
client.inbox.getInbox({
workspaceId,
newerThan: toDate(options.since),
olderThan: toDate(options.until),
limit,
archiveFilter: options.archiveFilter ?? 'active',
}),
fetchUnreadThreadIds(client, workspaceId),
fetchUnreadThreads(client, workspaceId),
])

let inboxThreads = threads.map((t) => ({
...t,
isUnread: unreadThreadIds.has(t.id),
}))
let inboxThreads = threads.map((t) => {
const unread = unreadThreads.get(t.id)
return {
...t,
isUnread: unread !== undefined,
hasUnreadMention: unread?.directMention ?? false,
}
})

if (options.mentions) {
inboxThreads = inboxThreads.filter((t) => t.hasUnreadMention)
}
if (options.unread) {
inboxThreads = inboxThreads.filter((t) => t.isUnread)
}
Expand Down Expand Up @@ -104,7 +112,7 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions
}
}

// Group by channel, unreads first within each channel, then sort by date (newest first)
// Group by channel; within each channel sort by tier (mention, unread, read), then newest first
const groupedByChannel = new Map<string, typeof inboxThreads>()
for (const thread of inboxThreads) {
const group = groupedByChannel.get(thread.channelId) || []
Expand All @@ -115,11 +123,10 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions
const sortByDate = (a: (typeof inboxThreads)[0], b: (typeof inboxThreads)[0]) =>
new Date(b.posted).getTime() - new Date(a.posted).getTime()

const tier = (t: (typeof inboxThreads)[number]) => (t.hasUnreadMention ? 0 : t.isUnread ? 1 : 2)
const sortedChannelGroups: typeof inboxThreads = []
for (const [, threads] of groupedByChannel) {
const unreads = threads.filter((t) => t.isUnread).sort(sortByDate)
const reads = threads.filter((t) => !t.isUnread).sort(sortByDate)
sortedChannelGroups.push(...unreads, ...reads)
sortedChannelGroups.push(...threads.sort((a, b) => tier(a) - tier(b) || sortByDate(a, b)))
}

if (outputMode === 'ids-only') {
Expand Down Expand Up @@ -158,8 +165,11 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions
const title = thread.isUnread ? chalk.bold(thread.title) : thread.title
const time = colors.timestamp(formatRelativeDate(thread.posted))
const unreadBadge = thread.isUnread ? chalk.blue(isAccessible() ? ' (unread)' : ' *') : ''
const mentionBadge = thread.hasUnreadMention
? chalk.yellow(isAccessible() ? ' (mention)' : ' @')
: ''

console.log(` ${title}${unreadBadge}`)
console.log(` ${title}${unreadBadge}${mentionBadge}`)
console.log(` ${time} ${colors.timestamp(`id:${thread.id}`)}`)
console.log(` ${colors.url(thread.url)}`)
console.log('')
Expand All @@ -173,6 +183,10 @@ export function registerInboxCommand(program: Command): void {
.option('--workspace <ref>', 'Workspace ID or name')
.option('--channel <filter>', 'Filter by channel name (fuzzy match)')
.option('--unread', 'Only show unread threads')
.option(
'--mentions',
'Only show unread threads where you were mentioned (implies --unread)',
)
.addOption(
withCaseInsensitiveChoices(
new Option(
Expand All @@ -195,6 +209,7 @@ export function registerInboxCommand(program: Command): void {
Examples:
tdc inbox
tdc inbox --unread
tdc inbox --mentions
tdc inbox --archive-filter all
tdc inbox --archive-filter archived
tdc inbox --channel engineering --since 2025-01-01
Expand Down
1 change: 1 addition & 0 deletions src/lib/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const THREAD_ESSENTIAL_FIELDS = [
'commentCount',
'isArchived',
'isUnread',
'hasUnreadMention',
'url',
'reactions',
] as const
Expand Down
2 changes: 2 additions & 0 deletions src/lib/skills/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ All target command flags pass through (e.g. \`--json\`, \`--raw\`, \`--full\`).
\`\`\`bash
tdc inbox # Show inbox threads
tdc inbox --unread # Only unread threads
tdc inbox --mentions # Only unread threads where you were mentioned (implies --unread)
tdc inbox --archive-filter all # Show active + done threads
tdc inbox --archive-filter archived # Show only done threads
tdc inbox --channel <filter> # Filter by channel name (fuzzy)
Expand Down Expand Up @@ -465,6 +466,7 @@ tdc view https://comms.todoist.com/a/1585/msg/CbV8n2Kp4Qx6Rz9Lm3Va/m/CbS8n2Kp4Qx
**Check inbox and respond:**
\`\`\`bash
tdc inbox --unread --json
tdc inbox --mentions --json # Unread threads with an unread @mention of you
tdc thread view <thread-ref> --unread
tdc thread reply <thread-ref> "Thanks, I'll look into this."
tdc thread done <thread-ref> --yes
Expand Down
10 changes: 5 additions & 5 deletions src/lib/threads.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { CommsApi } from '@doist/comms-sdk'
import type { CommsApi, UnreadThread } from '@doist/comms-sdk'

/** Normalises the SDK's `{ data, version }` unread response into a Set for O(1) joins. */
export async function fetchUnreadThreadIds(
/** Normalises the SDK's `{ data, version }` unread response into a Map keyed by thread ID. */
export async function fetchUnreadThreads(
client: CommsApi,
workspaceId: number,
): Promise<Set<string>> {
): Promise<Map<string, UnreadThread>> {
const unread = await client.threads.getUnread(workspaceId)
return new Set(unread.data.map((u) => u.threadId))
return new Map(unread.data.map((u) => [u.threadId, u]))
}
Loading