Convert non-resolving [refs] in doc comments to backtick code-spans across 24 lib/ files (param/field names out of doc scope, method refs on other classes, non-API strings like regex char-classes and command ids). Verified 0 "unresolved doc reference" warnings via `dart doc --validate-links`. The dart-doc CI gate (test.yml) never ran before — Gitea Actions was inactive — so this debt had accumulated unchecked. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
133 lines
5.4 KiB
Dart
133 lines
5.4 KiB
Dart
/// Formatting for the per-session status line (T-145): model, permission
|
|
/// mode, and context-window token count, joined for the status-bar slot.
|
|
///
|
|
/// Context is a token *count*, not a percentage — the transcript carries
|
|
/// `message.usage` but not the model's context limit, and the model id
|
|
/// doesn't encode the 1M vs 200k tier, so a percentage would be guesswork.
|
|
library;
|
|
|
|
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
|
|
|
/// Build the status-bar line, e.g. `opus 4.7 · default · 21k ctx · $0.12`.
|
|
/// Includes rate-limit info when active. Empty string when there's nothing to show.
|
|
String formatStatusLine(SessionStatus status) {
|
|
final parts = [
|
|
if (status.model != null) shortModelLabel(status.model!),
|
|
if (status.permissionMode != null) permissionModeLabel(status.permissionMode!),
|
|
if (status.contextTokens != null) _contextLabel(status),
|
|
if (status.cost != null) '\$${status.cost!.toStringAsFixed(2)}',
|
|
if (status.rateLimitInfo != null) status.rateLimitInfo!,
|
|
];
|
|
return parts.join(' · ');
|
|
}
|
|
|
|
/// Context token count, optionally shown as a fraction when the window
|
|
/// size is known: `21k / 1M ctx` vs `21k ctx`.
|
|
String _contextLabel(SessionStatus status) {
|
|
final tokens = formatTokenCount(status.contextTokens!);
|
|
if (status.contextWindow != null) {
|
|
final window = formatTokenCount(status.contextWindow!);
|
|
return '$tokens / $window ctx';
|
|
}
|
|
return '$tokens ctx';
|
|
}
|
|
|
|
/// `claude-opus-4-7` → `opus 4.7`; unknown shapes pass through.
|
|
String shortModelLabel(String model) {
|
|
final s = model.startsWith('claude-') ? model.substring('claude-'.length) : model;
|
|
final parts = s.split('-');
|
|
if (parts.length >= 2) return '${parts.first} ${parts.sublist(1).join('.')}';
|
|
return s;
|
|
}
|
|
|
|
/// The safe permission-mode cycle: default → acceptEdits → plan → default
|
|
/// (T-226/T-181). `bypassPermissions` is intentionally excluded — it's
|
|
/// reachable only via an explicit confirmed path (the footgun guard).
|
|
const List<String> kSafePermissionCycle = ['default', 'acceptEdits', 'plan'];
|
|
|
|
/// The next mode in [kSafePermissionCycle] after [current] (wraps). An
|
|
/// unknown or `bypassPermissions` current restarts the cycle at `default`.
|
|
String nextSafePermissionMode(String current) {
|
|
final i = kSafePermissionCycle.indexOf(current);
|
|
return kSafePermissionCycle[(i + 1) % kSafePermissionCycle.length];
|
|
}
|
|
|
|
/// Status-line segments split around the permission-mode badge so the UI can
|
|
/// render the mode as an interactive control between them (T-226). `leading`
|
|
/// is the model; `trailing` joins context / cost / rate-limit. Either may be
|
|
/// null when there's nothing to show.
|
|
({String? leading, String? trailing}) statusSegmentsAroundMode(SessionStatus s) {
|
|
final trailing = [
|
|
if (s.contextTokens != null) _contextLabel(s),
|
|
if (s.cost != null) '\$${s.cost!.toStringAsFixed(2)}',
|
|
if (s.rateLimitInfo != null) s.rateLimitInfo!,
|
|
].join(' · ');
|
|
return (leading: s.model != null ? shortModelLabel(s.model!) : null, trailing: trailing.isEmpty ? null : trailing);
|
|
}
|
|
|
|
/// Friendly label for Claude's permission modes.
|
|
String permissionModeLabel(String mode) {
|
|
switch (mode) {
|
|
case 'acceptEdits':
|
|
return 'accept-edits';
|
|
case 'bypassPermissions':
|
|
return 'bypass';
|
|
case 'plan':
|
|
return 'plan';
|
|
case 'default':
|
|
return 'default';
|
|
default:
|
|
return mode;
|
|
}
|
|
}
|
|
|
|
/// Configured-skills count for the status line (`12 skills`), from
|
|
/// ClaudeConfig — the *environment* side alongside the live session fields
|
|
/// (T-154). Null when there are none to show.
|
|
String? formatSkillsLabel(int count) => count > 0 ? '$count skill${count == 1 ? '' : 's'}' : null;
|
|
|
|
/// Compact token count: `765k`, `1.2M`, or the raw number under 1k.
|
|
String formatTokenCount(int n) {
|
|
if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M';
|
|
if (n >= 1000) return '${(n / 1000).round()}k';
|
|
return '$n';
|
|
}
|
|
|
|
/// Parsed `/usage` output (T-415). The CLI answers a forwarded `/usage`
|
|
/// headless and free (probed 2.1.175, num_turns 0) with plain text:
|
|
///
|
|
/// Current session: 15% used · resets Jun 12, 3:39pm (Europe/Amsterdam)
|
|
/// Current week (all models): 53% used · resets Jun 15, 6:59pm (…)
|
|
/// Current week (Sonnet only): 0% used
|
|
class ClaudeUsage {
|
|
const ClaudeUsage({this.session, this.week, this.weekSonnet});
|
|
|
|
/// The value text per line (e.g. `15% used · resets Jun 12, 3:39pm`),
|
|
/// timezone parenthetical stripped. Null when the line wasn't present.
|
|
final String? session;
|
|
final String? week;
|
|
final String? weekSonnet;
|
|
|
|
bool get isEmpty => session == null && week == null && weekSonnet == null;
|
|
}
|
|
|
|
/// Parse `/usage` response text into a [ClaudeUsage], or null when [text]
|
|
/// isn't usage output. Tolerant of label drift: any `Current …: …% used`
|
|
/// line is matched by its key phrase.
|
|
ClaudeUsage? parseUsageText(String text) {
|
|
if (!text.contains('% used')) return null;
|
|
String? valueOf(String keyPhrase) {
|
|
for (final line in text.split('\n')) {
|
|
if (!line.contains(keyPhrase)) continue;
|
|
final colon = line.indexOf(':');
|
|
if (colon < 0) continue;
|
|
// Strip the trailing timezone parenthetical — noise at sidebar width.
|
|
return line.substring(colon + 1).replaceAll(RegExp(r'\s*\([^)]*\)\s*$'), '').trim();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
final usage = ClaudeUsage(session: valueOf('Current session'), week: valueOf('(all models)'), weekSonnet: valueOf('(Sonnet only)'));
|
|
return usage.isEmpty ? null : usage;
|
|
}
|