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
4 changes: 2 additions & 2 deletions CODEBASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ don't duplicate it here.
- **`search-api.ts` / `search-helpers.ts`** — extended search params/response
(`extendedSearch`) + shared `--search`/options wiring (`addSharedSearchOptions`,
`runSearch`, `printSearchResults`). See `docs/comms-search.md`.
- **`threads.ts`** — `fetchUnreadThreadIds`; **`public-channels.ts`** —
- **`threads.ts`** — `fetchUnreadThreads`, `unreadFlags`; **`public-channels.ts`** —
public-channel id cache + `assertChannelIsPublic`.
- **`spinner.ts`** — re-exports `LoadingSpinner`, `withSpinner`,
`startEarlySpinner`, `stopEarlySpinner` from cli-core.
Expand All @@ -176,7 +176,7 @@ don't duplicate it here.
## Canonical examples

- **Read:** `src/commands/inbox.ts` — `getCommsClient()`, parallel fetches
(`getInbox` + `fetchUnreadThreadIds`), public-channel handling, then
(`getInbox` + `fetchUnreadThreads`), public-channel handling, then
`formatJson` / `formatNdjson` / `printEmpty`.
- **Mutation with `--json`:** `src/commands/channel/create.ts` — the reference
impl for `MutationOptions`, `ensureWriteAllowed`, `printDryRun`, and
Expand Down
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
7 changes: 4 additions & 3 deletions src/commands/channel/threads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ function setupClient({
unread = [],
}: {
threads?: Thread[]
unread?: { threadId: string }[] | null
unread?: { threadId: string; directMention?: boolean }[] | null
} = {}) {
const mockGetThreads = vi.fn().mockResolvedValue(threads)
const mockGetUnread = vi.fn().mockResolvedValue({ data: unread ?? [], version: 1 })
Expand Down Expand Up @@ -505,10 +505,10 @@ describe('channel threads', () => {
expect(consoleSpy).toHaveBeenCalledWith('No threads in #general.')
})

it('--json emits isUnread and url without --full', async () => {
it('--json emits isUnread, hasUnreadMention and url without --full', async () => {
setupClient({
threads: [createThread(1)],
unread: [{ threadId: '1' }],
unread: [{ threadId: '1', directMention: true }],
})
const consoleSpy = captureConsole('log')
const program = createProgram()
Expand All @@ -519,6 +519,7 @@ describe('channel threads', () => {
expect(output.results[0]).toMatchObject({
id: '1',
isUnread: true,
hasUnreadMention: true,
url: 'https://comms.todoist.com/a/1/ch/CH100/t/1',
})
})
Expand Down
14 changes: 7 additions & 7 deletions src/commands/channel/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unreadFlags, type UnreadThreadMap } from '../../lib/threads.js'
import { decodeCursor, encodeCursor } from './helpers.js'

type ChannelThreadsOptions = PaginatedViewOptions & {
Expand All @@ -24,7 +24,7 @@ type ChannelThreadsOptions = PaginatedViewOptions & {
cursor?: string
}

type DecoratedThread = Thread & { isUnread: boolean }
type DecoratedThread = Thread & ReturnType<typeof unreadFlags>

function archiveFilterToFlag(filter: ArchiveFilter | undefined): boolean | undefined {
switch (filter ?? 'active') {
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<UnreadThreadMap>(new Map()),
])

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),
...unreadFlags(thread.id, unreadThreads),
}))
const paginated: PaginatedOutput<DecoratedThread> = {
results: decoratedPage,
Expand Down
101 changes: 101 additions & 0 deletions src/commands/inbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,104 @@ 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',
},
{
id: 'thread-unread-older',
channelId: 'CH1',
title: 'Older plain unread',
posted: '2026-04-30T00:00:00Z',
url: 'https://example.test/thread-unread-older',
},
]
const unreadData = [
{ threadId: 'thread-unread', channelId: 'CH1', objIndex: 3, directMention: false },
{ threadId: 'thread-unread-older', channelId: 'CH1', objIndex: 1, 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 by tier, then newest first within a tier', async () => {
await createProgram().parseAsync(['node', 'tdc', 'inbox', '--json'])

expect(parsedJsonOutput().map((t) => t.id)).toEqual([
'thread-mention',
'thread-unread',
'thread-unread-older',
'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')
})
})
35 changes: 22 additions & 13 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, unreadFlags } from '../lib/threads.js'

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

Expand All @@ -42,22 +43,22 @@ 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) => ({ ...t, ...unreadFlags(t.id, unreadThreads) }))

if (options.mentions) {
inboxThreads = inboxThreads.filter((t) => t.hasUnreadMention)
}
if (options.unread) {
inboxThreads = inboxThreads.filter((t) => t.isUnread)
}
Expand Down Expand Up @@ -104,22 +105,22 @@ 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, then order within each channel
const groupedByChannel = new Map<string, typeof inboxThreads>()
for (const thread of inboxThreads) {
const group = groupedByChannel.get(thread.channelId) || []
group.push(thread)
groupedByChannel.set(thread.channelId, group)
}

const sortByDate = (a: (typeof inboxThreads)[0], b: (typeof inboxThreads)[0]) =>
type InboxThread = (typeof inboxThreads)[number]
const sortByDate = (a: InboxThread, b: InboxThread) =>
new Date(b.posted).getTime() - new Date(a.posted).getTime()

const tier = (t: InboxThread) => (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 +159,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 +177,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 +203,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
Loading
Loading