Skip to content
Open
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ All widgets must implement:
**Available Widgets:**
- Model, Version, OutputStyle, VoiceStatus - Claude Code metadata display
- GitBranch, GitChanges, GitInsertions, GitDeletions, GitWorktree - Git repository status
- JjBookmarks, JjRevision, JjChanges, JjDescription, JjWorkspace - Jujutsu (jj) repository status
- AtomicView, AtomicChange, AtomicDescription, AtomicChanges, AtomicInsertions, AtomicDeletions, AtomicRootDir - Atomic VCS status (detects `.atomic` repo; shows configurable `no atomic` fallback). Utilities in src/utils/atomic.ts wrap the `atomic` CLI (`status --short` for repo detection, `diff --stat`, `view list`, `change`) and walk up for `.atomic` to find the repo root
- TokensInput, TokensOutput, TokensCached, TokensTotal - Token usage metrics
- ContextLength, ContextPercentage, ContextPercentageUsable - Context window metrics (uses dynamic model-based context windows: 1M for Sonnet 4.5 with [1m] suffix, 200k for all other models)
- BlockTimer, SessionClock, SessionCost - Time and cost tracking
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@

## 🆕 Recent Updates

### Unreleased - Atomic VCS widgets

- **⚛️ Atomic version control support** - Added widgets for the [Atomic](https://atomic.dev) VCS: `Atomic View` (current view/branch), `Atomic Change` (current change hash), `Atomic Description` (change message), `Atomic Changes`/`Atomic Insertions`/`Atomic Deletions` (working-copy diff stats), and `Atomic Root Dir`. Each detects whether the working directory is inside an Atomic repository and shows a configurable `no atomic` message otherwise.

### v2.2.22 - v2.2.23 - Powerline flex mode, layout controls, composable metrics, and safer config

![Powerline Flex Mode](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/powerline-flex.png)
Expand Down
76 changes: 76 additions & 0 deletions src/utils/atomic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { execFileSync } from 'child_process';
import {
existsSync,
statSync
} from 'fs';
import {
dirname,
join
} from 'path';

import type { RenderContext } from '../types/RenderContext';

import { resolveGitCwd } from './git';

export interface AtomicChangeCounts {
insertions: number;
deletions: number;
}

export function runAtomicArgs(args: string[], context: RenderContext, allowEmpty = false): string | null {
try {
const cwd = resolveGitCwd(context);
const output = execFileSync('atomic', args, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
windowsHide: true,
...(cwd ? { cwd } : {})
}).trimEnd();

return (allowEmpty || output.length > 0) ? output : null;
} catch {
return null;
}
}

export function isInsideAtomicRepo(context: RenderContext): boolean {
// `atomic status --short` exits 0 inside a repository (even when clean, with
// empty output) and non-zero otherwise, so allow empty output here.
return runAtomicArgs(['status', '--short'], context, true) !== null;
}

function parseDiffStat(stat: string): AtomicChangeCounts {
const insertMatch = /(\d+)\s+insertions?/.exec(stat);
const deleteMatch = /(\d+)\s+deletions?/.exec(stat);

return {
insertions: insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0,
deletions: deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0
};
}

export function getAtomicChangeCounts(context: RenderContext): AtomicChangeCounts {
return parseDiffStat(runAtomicArgs(['diff', '--stat'], context) ?? '');
}

// Atomic has no `root` subcommand, so walk up from the working directory looking
// for a `.atomic` directory, mirroring how git/jj resolve their repository root.
export function findAtomicRoot(context: RenderContext): string | null {
let current = resolveGitCwd(context) ?? process.cwd();

for (let parent = dirname(current); ; parent = dirname(current)) {
const marker = join(current, '.atomic');
try {
if (existsSync(marker) && statSync(marker).isDirectory()) {
return current;
}
} catch {
// Ignore filesystem errors and keep walking upward.
}

if (parent === current) {
return null;
}
current = parent;
}
}
7 changes: 7 additions & 0 deletions src/utils/widget-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [
{ type: 'jj-deletions', create: () => new widgets.JjDeletionsWidget() },
{ type: 'jj-description', create: () => new widgets.JjDescriptionWidget() },
{ type: 'jj-revision', create: () => new widgets.JjRevisionWidget() },
{ type: 'atomic-view', create: () => new widgets.AtomicViewWidget() },
{ type: 'atomic-root-dir', create: () => new widgets.AtomicRootDirWidget() },
{ type: 'atomic-changes', create: () => new widgets.AtomicChangesWidget() },
{ type: 'atomic-insertions', create: () => new widgets.AtomicInsertionsWidget() },
{ type: 'atomic-deletions', create: () => new widgets.AtomicDeletionsWidget() },
{ type: 'atomic-description', create: () => new widgets.AtomicDescriptionWidget() },
{ type: 'atomic-change', create: () => new widgets.AtomicChangeWidget() },
{ type: 'current-working-dir', create: () => new widgets.CurrentWorkingDirWidget() },
{ type: 'tokens-input', create: () => new widgets.TokensInputWidget() },
{ type: 'tokens-output', create: () => new widgets.TokensOutputWidget() },
Expand Down
86 changes: 86 additions & 0 deletions src/widgets/AtomicChange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { RenderContext } from '../types/RenderContext';
import type { Settings } from '../types/Settings';
import type {
CustomKeybind,
Widget,
WidgetEditorDisplay,
WidgetItem
} from '../types/Widget';
import {
isInsideAtomicRepo,
runAtomicArgs
} from '../utils/atomic';

export class AtomicChangeWidget implements Widget {
getDefaultColor(): string { return 'green'; }
getDescription(): string { return 'Shows the current atomic change hash'; }
getDisplayName(): string { return 'Atomic Change'; }
getCategory(): string { return 'Atomic'; }
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
const hideNoAtomic = item.metadata?.hideNoAtomic === 'true';
const modifiers: string[] = [];

if (hideNoAtomic) {
modifiers.push('hide \'no atomic\'');
}

return {
displayText: this.getDisplayName(),
modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined
};
}

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
if (action === 'toggle-noatomic') {
const currentState = item.metadata?.hideNoAtomic === 'true';
return {
...item,
metadata: {
...item.metadata,
hideNoAtomic: (!currentState).toString()
}
};
}
return null;
}

render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null {
const hideNoAtomic = item.metadata?.hideNoAtomic === 'true';

if (context.isPreview) {
return item.rawValue ? 'KQNCPFYKX576' : ' KQNCPFYKX576';
}

if (!isInsideAtomicRepo(context)) {
return hideNoAtomic ? null : ' no atomic';
}

const hash = this.getAtomicChange(context);
if (hash) {
return item.rawValue ? hash : ` ${hash}`;
}

return hideNoAtomic ? null : ' no atomic';
}

private getAtomicChange(context: RenderContext): string | null {
const output = runAtomicArgs(['change'], context);
if (!output) {
return null;
}

// First line looks like: `change KQNCPFYKX576 (#2)`
const firstLine = output.split(/\r?\n/)[0] ?? '';
const match = /^change\s+(\S+)/.exec(firstLine);
return match?.[1] ?? null;
}

getCustomKeybinds(): CustomKeybind[] {
return [
{ key: 'h', label: '(h)ide \'no atomic\' message', action: 'toggle-noatomic' }
];
}

supportsRawValue(): boolean { return true; }
supportsColors(): boolean { return true; }
}
70 changes: 70 additions & 0 deletions src/widgets/AtomicChanges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { RenderContext } from '../types/RenderContext';
import type { Settings } from '../types/Settings';
import type {
CustomKeybind,
Widget,
WidgetEditorDisplay,
WidgetItem
} from '../types/Widget';
import {
getAtomicChangeCounts,
isInsideAtomicRepo
} from '../utils/atomic';

export class AtomicChangesWidget implements Widget {
getDefaultColor(): string { return 'yellow'; }
getDescription(): string { return 'Shows atomic changes count (+insertions, -deletions)'; }
getDisplayName(): string { return 'Atomic Changes'; }
getCategory(): string { return 'Atomic'; }
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
const hideNoAtomic = item.metadata?.hideNoAtomic === 'true';
const modifiers: string[] = [];

if (hideNoAtomic) {
modifiers.push('hide \'no atomic\'');
}

return {
displayText: this.getDisplayName(),
modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined
};
}

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
if (action === 'toggle-noatomic') {
const currentState = item.metadata?.hideNoAtomic === 'true';
return {
...item,
metadata: {
...item.metadata,
hideNoAtomic: (!currentState).toString()
}
};
}
return null;
}

render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null {
const hideNoAtomic = item.metadata?.hideNoAtomic === 'true';

if (context.isPreview) {
return '(+42,-10)';
}

if (!isInsideAtomicRepo(context)) {
return hideNoAtomic ? null : '(no atomic)';
}

const changes = getAtomicChangeCounts(context);
return `(+${changes.insertions},-${changes.deletions})`;
}

getCustomKeybinds(): CustomKeybind[] {
return [
{ key: 'h', label: '(h)ide \'no atomic\' message', action: 'toggle-noatomic' }
];
}

supportsRawValue(): boolean { return false; }
supportsColors(): boolean { return true; }
}
70 changes: 70 additions & 0 deletions src/widgets/AtomicDeletions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { RenderContext } from '../types/RenderContext';
import type { Settings } from '../types/Settings';
import type {
CustomKeybind,
Widget,
WidgetEditorDisplay,
WidgetItem
} from '../types/Widget';
import {
getAtomicChangeCounts,
isInsideAtomicRepo
} from '../utils/atomic';

export class AtomicDeletionsWidget implements Widget {
getDefaultColor(): string { return 'red'; }
getDescription(): string { return 'Shows atomic deletions count'; }
getDisplayName(): string { return 'Atomic Deletions'; }
getCategory(): string { return 'Atomic'; }
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
const hideNoAtomic = item.metadata?.hideNoAtomic === 'true';
const modifiers: string[] = [];

if (hideNoAtomic) {
modifiers.push('hide \'no atomic\'');
}

return {
displayText: this.getDisplayName(),
modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined
};
}

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
if (action === 'toggle-noatomic') {
const currentState = item.metadata?.hideNoAtomic === 'true';
return {
...item,
metadata: {
...item.metadata,
hideNoAtomic: (!currentState).toString()
}
};
}
return null;
}

render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null {
const hideNoAtomic = item.metadata?.hideNoAtomic === 'true';

if (context.isPreview) {
return '-10';
}

if (!isInsideAtomicRepo(context)) {
return hideNoAtomic ? null : '(no atomic)';
}

const changes = getAtomicChangeCounts(context);
return `-${changes.deletions}`;
}

getCustomKeybinds(): CustomKeybind[] {
return [
{ key: 'h', label: '(h)ide \'no atomic\' message', action: 'toggle-noatomic' }
];
}

supportsRawValue(): boolean { return false; }
supportsColors(item: WidgetItem): boolean { return true; }
}
Loading