[Sync] Lua Hyprland chain, licence cleanup

This commit is contained in:
Coja committed 2026-11-09 18:25:35 +01:00
1 parent 93b4f695ce
commit 47166d877f
183 files changed
+5618 -5839

No files matched your search

+1 -1
View File
@@ -51,7 +51,7 @@ clients:
- name: GLM-4.7-Flash-UD-Q3_K_XL
max_input_tokens: 22000 # ctx 24576 · ~28 t/s — fast coder (3-bit)
- name: Ornith-1.5-35B-A3B-IQ3_XXS
max_input_tokens: 22000 # ctx 24576 · ~32 t/s — agentic coder, thinking
max_input_tokens: 30000 # ctx 32768 · ~32 t/s — agentic coder, thinking
supports_vision: true
- name: gpt-oss-20b
max_input_tokens: 62000 # ctx 65536 · ~38 t/s — fast reasoning + tools
-663
View File
@@ -1,663 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Name: One Half Dark
Author: Son A. Pham <sp@sonpham.me>
Url: https://github.com/sonph/onehalf
License: The MIT License (MIT)
A dark Sublime Text color scheme based on Atom's One. See
github.com/sonph/onehalf for installation instructions, a light color
scheme, and versions for other editors/terminals such as Vim or iTerm.
Red: #e06c75
Green: #98c379
Yellow: #e5c07b
Blue: #61afef
Purple: #c678dd
Cyan: #56b6c2
White: #dcdfe4
Black: #282c34
Fg: #dcdfe4
Bg: #282c34
Comment: #5c6370
Gutter foreground: #919baa
Gutter background: #282c34
Selection: #474e5d
-->
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>name</key>
<string>OneHalfLight</string>
<key>semanticClass</key>
<string>theme.dark.one_half_dark</string>
<key>uuid</key>
<string></string>
<key>colorSpaceName</key>
<string>sRGB</string>
<key>author</key>
<string>Son A. Pham &lt;sp@sonpham.me&gt;</string>
<key>comment</key>
<string>A dark iTerm color scheme based on Atom&apos;s One. See github.com&#x2f;sonph&#x2f;onehalf for installation instructions, a light color scheme, and versions for other editors&#x2f;terminals such as (Neo)Vim and Sublime Text.</string>
<key>settings</key>
<array>
<dict>
<key>settings</key>
<dict>
<key>fontStyle</key>
<string></string>
<key>foreground</key>
<string>#dcdfe4</string>
<key>background</key>
<string>#282c34</string>
<key>bracketsOptions</key>
<string>underline</string>
<key>caret</key>
<string>#a3b3cc</string>
<key>gutter</key>
<string>#282c34</string>
<key>gutterForeground</key>
<string>#919baa</string>
<key>invisibles</key>
<string>#5c6370</string>
<key>lineHighlight</key>
<string>#313640</string>
<key>selection</key>
<string>#474e5d</string>
<key>selectionBorder</key>
<string>#474e5d</string>
<key>tagsForeground</key>
<string></string>
<key>tagsOptions</key>
<string>stippled_underline</string>
<key>bracketContentsOptions</key>
<string>underline</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Comments</string>
<key>scope</key>
<string>comment</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#5c6370</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Text</string>
<key>scope</key>
<string>variable.parameter.function</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#dcdfe4</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Delimiters</string>
<key>scope</key>
<string>none</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string></string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Operators</string>
<key>scope</key>
<string>keyword.operator</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string></string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Keywords</string>
<key>scope</key>
<string>keyword</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#c678dd</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Variables</string>
<key>scope</key>
<string>variable</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e06c75</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Functions</string>
<key>scope</key>
<string>entity.name.function, meta.require, support.function.any-method</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#61afef</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Classes</string>
<key>scope</key>
<string>support.class, entity.name.class, entity.name.type.class</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e5c07b</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Classes</string>
<key>scope</key>
<string>meta.class</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e5c07b</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Methods</string>
<key>scope</key>
<string>keyword.other.special-method</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#61afef</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Storage</string>
<key>scope</key>
<string>storage</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#c678dd</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Support</string>
<key>scope</key>
<string>support.function</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#61afef</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Strings, Inherited Class</string>
<key>scope</key>
<string>string</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#98c379</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Integers</string>
<key>scope</key>
<string>constant.numeric</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e5c07b</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Floats</string>
<key>scope</key>
<string>none</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e5c07b</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Boolean</string>
<key>scope</key>
<string>none</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e5c07b</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Constants</string>
<key>scope</key>
<string>constant</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e5c07b</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>HTML: Tags</string>
<key>scope</key>
<string>entity.name.tag</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e06c75</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>HTML: Tag attributes</string>
<key>scope</key>
<string>entity.other.attribute-name</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e5c07b</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Attribute IDs</string>
<key>scope</key>
<string>entity.other.attribute-name.id, punctuation.definition.entity</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e5c07b</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Selector</string>
<key>scope</key>
<string>meta.selector</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#c678dd</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Markdown: Headings</string>
<key>scope</key>
<string>markup.heading punctuation.definition.heading, entity.name.section</string>
<key>settings</key>
<dict>
<key>fontStyle</key>
<string></string>
<key>foreground</key>
<string>#61afef</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Markdown: Bold</string>
<key>scope</key>
<string>markup.bold, punctuation.definition.bold</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#c678dd</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Markdown: Italic</string>
<key>scope</key>
<string>markup.italic, punctuation.definition.italic</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#c678dd</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Markdown: Code</string>
<key>scope</key>
<string>markup.raw.inline</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#98c379</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Markdown: Link Text</string>
<key>scope</key>
<string>string.other.link, punctuation.definition.string.end.markdown</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string></string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Markdown: Link Url</string>
<key>scope</key>
<string>meta.link</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#98c379</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Markdown: Lists</string>
<key>scope</key>
<string>markup.list</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string></string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Markdown: Quotes</string>
<key>scope</key>
<string>markup.quote</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#98c379</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Java Source</string>
<key>scope</key>
<string>source.java meta.class.java meta.method.java</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#dcdfe4</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Java Class Body</string>
<key>scope</key>
<string>source.java meta.class.java meta.class.body.java</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#dcdfe4</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Javascript: Function Arguments</string>
<key>scope</key>
<string>source.js meta.function.js variable.parameter.function.js</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e06c75</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Javascript: New Variables</string>
<key>scope</key>
<string>source.js variable.other.readwrite.js</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e06c75</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Javascript: Variables</string>
<key>scope</key>
<string>source.js variable.other.object.js</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#dcdfe4</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Javascript: Variables in Function Calls</string>
<key>scope</key>
<string>source.js meta.function-call.method.js variable.other.readwrite.js</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e06c75</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Javascript: New Block Variables</string>
<key>scope</key>
<string>source.js meta.block.js variable.other.readwrite.js</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e06c75</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Javascript: Block Variables</string>
<key>scope</key>
<string>source.js meta.block.js variable.other.object.js</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#dcdfe4</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Javascript: Block Variables in Function Calls</string>
<key>scope</key>
<string>source.js meta.block.js meta.function-call.method.js variable.other.readwrite.js</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#dcdfe4</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Javascript: Function Calls</string>
<key>scope</key>
<string>source.js meta.function-call.method.js variable.function.js</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#dcdfe4</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Javascript: Properties</string>
<key>scope</key>
<string>source.js meta.property.object.js entity.name.function.js</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#61afef</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Javascript: Prototypes</string>
<key>scope</key>
<string>source.js support.constant.prototype.js</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#dcdfe4</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Separator</string>
<key>scope</key>
<string>meta.separator</string>
<key>settings</key>
<dict>
<key>background</key>
<string></string>
<key>foreground</key>
<string></string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Inserted</string>
<key>scope</key>
<string>markup.inserted</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#98c379</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Deleted</string>
<key>scope</key>
<string>markup.deleted</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e06c75</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Changed</string>
<key>scope</key>
<string>markup.changed</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e5c07b</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Regular Expressions</string>
<key>scope</key>
<string>string.regexp</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#98c379</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Escape Characters</string>
<key>scope</key>
<string>constant.character.escape</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#56b6c2</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Embedded</string>
<key>scope</key>
<string>punctuation.section.embedded, variable.interpolation</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string></string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Illegal</string>
<key>scope</key>
<string>invalid.illegal</string>
<key>settings</key>
<dict>
<key>background</key>
<string>#e06c75</string>
<key>foreground</key>
<string>#dcdfe4</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Broken</string>
<key>scope</key>
<string>invalid.broken</string>
<key>settings</key>
<dict>
<key>background</key>
<string>#e5c07b</string>
<key>foreground</key>
<string>#dcdfe4</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Deprecated</string>
<key>scope</key>
<string>invalid.deprecated</string>
<key>settings</key>
<dict>
<key>background</key>
<string>#e5c07b</string>
<key>foreground</key>
<string>#dcdfe4</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Unimplemented</string>
<key>scope</key>
<string>invalid.unimplemented</string>
<key>settings</key>
<dict>
<key>background</key>
<string>#c678dd</string>
<key>foreground</key>
<string>#dcdfe4</string>
</dict>
</dict>
</array>
</dict>
</plist>
+1 -1
View File
@@ -4,7 +4,7 @@
# Specify desired highlighting theme (e.g. "TwoDark"). Run `bat --list-themes`
# for a list of all available themes
# Custom theme lives in ./themes/ — run `bat cache --build` after changing it
# OneHalfDark is built into bat — no custom themes are tracked, so no `bat cache --build` step
--theme="OneHalfDark"
--style="numbers,changes,header,snip"
-260
View File
@@ -1,260 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>name</key>
<string>One Dark (NvChad base46)</string>
<key>uuid</key>
<string>3c1e2b84-9d5f-4a7c-8b2e-6f4a1d9c7e5b</string>
<key>settings</key>
<array>
<dict>
<key>settings</key>
<dict>
<key>background</key>
<string>#1e222a</string>
<key>foreground</key>
<string>#abb2bf</string>
<key>caret</key>
<string>#abb2bf</string>
<key>selection</key>
<string>#3e4451</string>
<key>lineHighlight</key>
<string>#252931</string>
<key>gutterForeground</key>
<string>#545862</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Comment</string>
<key>scope</key>
<string>comment, punctuation.definition.comment</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#6f737b</string>
<key>fontStyle</key>
<string>italic</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>String</string>
<key>scope</key>
<string>string</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#98c379</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Number, constant</string>
<key>scope</key>
<string>constant.numeric, constant.language, constant.other</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#d19a66</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Escape character</string>
<key>scope</key>
<string>constant.character.escape</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#a3b8ef</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Keyword, storage</string>
<key>scope</key>
<string>keyword, storage</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#c882e7</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Operator</string>
<key>scope</key>
<string>keyword.operator</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#abb2bf</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Function</string>
<key>scope</key>
<string>entity.name.function, support.function, meta.function-call.generic</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#61afef</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Class, type</string>
<key>scope</key>
<string>entity.name.class, entity.name.type, entity.name.struct, entity.name.enum, support.type, support.class</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e7c787</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Variable</string>
<key>scope</key>
<string>variable, variable.other</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e06c75</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Function parameter</string>
<key>scope</key>
<string>variable.parameter</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e06c75</string>
<key>fontStyle</key>
<string>italic</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Attribute</string>
<key>scope</key>
<string>entity.other.attribute-name</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#d19a66</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Tag</string>
<key>scope</key>
<string>entity.name.tag, meta.tag</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e06c75</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Markup heading</string>
<key>scope</key>
<string>markup.heading, entity.name.section</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e06c75</string>
<key>fontStyle</key>
<string>bold</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Markup bold</string>
<key>scope</key>
<string>markup.bold</string>
<key>settings</key>
<dict>
<key>fontStyle</key>
<string>bold</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Markup italic</string>
<key>scope</key>
<string>markup.italic</string>
<key>settings</key>
<dict>
<key>fontStyle</key>
<string>italic</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Markup link</string>
<key>scope</key>
<string>markup.underline.link, string.other.link</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#61afef</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Markup quote</string>
<key>scope</key>
<string>markup.quote</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#6f737b</string>
<key>fontStyle</key>
<string>italic</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Diff inserted</string>
<key>scope</key>
<string>markup.inserted</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#98c379</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Diff deleted</string>
<key>scope</key>
<string>markup.deleted</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#e06c75</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Invalid</string>
<key>scope</key>
<string>invalid, invalid.illegal</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#ffffff</string>
<key>background</key>
<string>#be5046</string>
</dict>
</dict>
</array>
</dict>
</plist>
@@ -1,262 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>name</key>
<string>Synthwave 84</string>
<key>uuid</key>
<string>7b2c3a10-4b6e-4d8f-9c1a-2e5f8d7a9b3c</string>
<key>settings</key>
<array>
<dict>
<key>settings</key>
<dict>
<key>background</key>
<string>#262335</string>
<key>foreground</key>
<string>#ffffff</string>
<key>caret</key>
<string>#f97e72</string>
<key>selection</key>
<string>#463465</string>
<key>lineHighlight</key>
<string>#2a2139</string>
<key>gutterForeground</key>
<string>#848bbd</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Comment</string>
<key>scope</key>
<string>comment, punctuation.definition.comment</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#848bbd</string>
<key>fontStyle</key>
<string>italic</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>String</string>
<key>scope</key>
<string>string</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#ff8b39</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Number, constant</string>
<key>scope</key>
<string>constant.numeric, constant.language, constant.other</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#f97e72</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Escape character</string>
<key>scope</key>
<string>constant.character.escape</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#36f9f6</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Keyword, storage</string>
<key>scope</key>
<string>keyword, storage, keyword.operator</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#fede5d</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Storage type</string>
<key>scope</key>
<string>storage.type, storage.modifier</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#fede5d</string>
<key>fontStyle</key>
<string>italic</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Function</string>
<key>scope</key>
<string>entity.name.function, support.function, meta.function-call.generic</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#36f9f6</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Class, type</string>
<key>scope</key>
<string>entity.name.class, entity.name.type, entity.name.struct, entity.name.enum, support.type, support.class</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#fe4450</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Variable</string>
<key>scope</key>
<string>variable, variable.other</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#ff7edb</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Function parameter</string>
<key>scope</key>
<string>variable.parameter</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#ff7edb</string>
<key>fontStyle</key>
<string>italic</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Attribute</string>
<key>scope</key>
<string>entity.other.attribute-name</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#fede5d</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Tag</string>
<key>scope</key>
<string>entity.name.tag, meta.tag</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#72f1b8</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Markup heading</string>
<key>scope</key>
<string>markup.heading, entity.name.section</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#fe4450</string>
<key>fontStyle</key>
<string>bold</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Markup bold</string>
<key>scope</key>
<string>markup.bold</string>
<key>settings</key>
<dict>
<key>fontStyle</key>
<string>bold</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Markup italic</string>
<key>scope</key>
<string>markup.italic</string>
<key>settings</key>
<dict>
<key>fontStyle</key>
<string>italic</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Markup link</string>
<key>scope</key>
<string>markup.underline.link, string.other.link</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#36f9f6</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Markup quote</string>
<key>scope</key>
<string>markup.quote</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#848bbd</string>
<key>fontStyle</key>
<string>italic</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Diff inserted</string>
<key>scope</key>
<string>markup.inserted</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#72f1b8</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Diff deleted</string>
<key>scope</key>
<string>markup.deleted</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#fe4450</string>
</dict>
</dict>
<dict>
<key>name</key>
<string>Invalid</string>
<key>scope</key>
<string>invalid, invalid.illegal</string>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#ffffff</string>
<key>background</key>
<string>#fe4450</string>
</dict>
</dict>
</array>
</dict>
</plist>
+4 -294
View File
@@ -1,241 +1,13 @@
## Configuration file for CAVA.
# Remove the ; to change parameters.
## cava — only this repo's own settings. Every key left out falls back to cava's built-in
## default; cava documents them all in the config it writes to an empty ~/.config/cava/config,
## and it regenerates its own shaders/ and themes/ there on first run (so the repo ships none
## of cava's files).
[general]
# Smoothing mode. Can be 'normal', 'scientific' or 'waves'. DEPRECATED as of 0.6.0
# mode = scientific # deprecated since cava 0.6.0, ignored
# Accepts only non-negative values.
framerate = 60
# 'autosens' will attempt to decrease sensitivity if the bars peak. 1 = on, 0 = off
# new as of 0.6.0 autosens of low values (dynamic range)
# 'overshoot' allows bars to overshoot (in % of terminal height) without initiating autosens. DEPRECATED as of 0.6.0
autosens = 1
; overshoot = 20
# Manual sensitivity in %. If autosens is enabled, this will only be the initial value.
# 200 means double height. Accepts only non-negative values.
; sensitivity = 100
# The number of bars (0-512). 0 sets it to auto (fill up console).
# Bars' width and space between bars in number of characters.
; bars = 0
; bar_width = 2
; bar_spacing = 1
# bar_height is only used for output in "noritake" format
; bar_height = 32
# For SDL width and space between bars is in pixels, defaults are:
; bar_width = 20
; bar_spacing = 5
# sdl_glsl have these default values, they are only used to calculate max number of bars.
; bar_width = 1
; bar_spacing = 0
# ceter bars in terminal, if there is space.
; center_align = 1
# max height of bars in terminal, in percent of terminal height.
; max_height = 100
# Lower and higher cutoff frequencies for lowest and highest bars
# the bandwidth of the visualizer.
# Note: there is a minimum total bandwidth of 43Mhz x number of bars.
# Cava will automatically increase the higher cutoff if a too low band is specified.
; lower_cutoff_freq = 50
; higher_cutoff_freq = 10000
# Seconds with no input before cava goes to sleep mode. Cava will not perform FFT or drawing and
# only check for input once per second. Cava will wake up once input is detected. 0 = disable.
; sleep_timer = 0
[input]
# Audio capturing method. Possible methods are: 'fifo', 'portaudio', 'pipewire', 'alsa', 'pulse', 'sndio', 'oss', 'jack' or 'shmem'
# Defaults to 'oss', 'pipewire', 'sndio', 'jack', 'pulse', 'alsa', 'portaudio' or 'fifo', in that order, dependent on what support cava was built with.
# On Mac it defaults to 'portaudio' or 'fifo'
# On windows this is automatic and no input settings are needed.
#
# All input methods uses the same config variable 'source'
# to define where it should get the audio.
#
# For pulseaudio and pipewire 'source' will be the source. Default: 'auto', which uses the monitor source of the default sink
# (all pulseaudio sinks(outputs) have 'monitor' sources(inputs) associated with them).
#
# For pipewire 'source' will be the object name or object.serial of the device to capture from.
# Both input and output devices are supported. To capture the monitor source of a sink node, append '.monitor' to the sink's object name.
#
# For alsa 'source' will be the capture device.
# For fifo 'source' will be the path to fifo-file.
# For shmem 'source' will be /squeezelite-AA:BB:CC:DD:EE:FF where 'AA:BB:CC:DD:EE:FF' will be squeezelite's MAC address
#
# For sndio 'source' will be a raw recording audio descriptor or a monitoring sub-device, e.g. 'rsnd/2' or 'snd/1'. Default: 'default'.
# README.md contains further information on how to setup CAVA for sndio.
#
# For oss 'source' will be the path to a audio device, e.g. '/dev/dsp2'. Default: '/dev/dsp', i.e. the default audio device.
# README.md contains further information on how to setup CAVA for OSS on FreeBSD.
#
# For jack 'source' will be the name of the JACK server to connect to, e.g. 'foobar'. Default: 'default'.
# README.md contains further information on how to setup CAVA for JACK.
#
; method = pulse
; source = auto
; method = pipewire
; source = auto
; method = alsa
; source = hw:Loopback,1
; method = fifo
; source = /tmp/mpd.fifo
; method = shmem
; source = /squeezelite-AA:BB:CC:DD:EE:FF
; method = portaudio
; source = auto
; method = sndio
; source = default
; method = oss
; source = /dev/dsp
; method = jack
; source = default
# The options 'sample_rate', 'sample_bits', 'channels' and 'autoconnect' can be configured for some input methods:
# sample_rate: fifo, pipewire, sndio, oss
# sample_bits: fifo, pipewire, sndio, oss
# channels: sndio, oss, jack
# autoconnect: jack
# Other methods ignore these settings.
#
# For 'sndio' and 'oss' they are only preferred values, i.e. if the values are not supported
# by the chosen audio device, the device will use other supported values instead.
# Example: 48000, 32 and 2, but the device only supports 44100, 16 and 1, then it
# will use 44100, 16 and 1.
#
; sample_rate = 44100
; sample_bits = 16
; channels = 2
; autoconnect = 2
[output]
# Output method. Can be 'ncurses', 'noncurses', 'raw', 'noritake', 'sdl'
# or 'sdl_glsl'.
# 'noncurses' (default) uses a buffer and cursor movements to only print
# changes from frame to frame in the terminal. Uses less resources and is less
# prone to tearing (vsync issues) than 'ncurses'.
#
# 'raw' is an 8 or 16 bit (configurable via the 'bit_format' option) data
# stream of the bar heights that can be used to send to other applications.
# 'raw' defaults to 1024 bars stereo (512 bars mono), which can be adjusted in the 'bars' option above.
#
# 'noritake' outputs a bitmap in the format expected by a Noritake VFD display
# in graphic mode. It only support the 3000 series graphical VFDs for now.
#
# 'sdl' uses the Simple DirectMedia Layer to render in a graphical context.
# 'sdl_glsl' uses SDL to create an OpenGL context. Write your own shaders or
# use one of the predefined ones.
; method = noncurses
# Orientation of the visualization. Can be 'bottom', 'top', 'left', 'right' or
# 'horizontal'. Default is 'bottom'. 'left and 'right' are only supported on sdl
# and ncruses output. 'horizontal' (bars go up and down from center) is only supported
# on noncurses output.
# Note: many fonts have weird or missing glyphs for characters used in orientations
# other than 'bottom', which can make output not look right.
; orientation = bottom
# Visual channels. Can be 'stereo' or 'mono'.
# 'stereo' mirrors both channels with low frequencies in center.
# 'mono' outputs left to right lowest to highest frequencies.
# 'mono_option' set mono to either take input from 'left', 'right' or 'average'.
# set 'reverse' to 1 to display frequencies the other way around.
; channels = stereo
; mono_option = average
; reverse = 0
# Raw output target.
# On Linux, a fifo will be created if target does not exist.
# On Windows, a named pipe will be created if target does not exist.
; raw_target = /dev/stdout
# Raw data format. Can be 'binary' or 'ascii'.
; data_format = binary
# Binary bit format, can be '8bit' (0-255) or '16bit' (0-65530).
; bit_format = 16bit
# Ascii max value. In 'ascii' mode range will run from 0 to value specified here
; ascii_max_range = 1000
# Ascii delimiters. In ascii format each bar and frame is separated by a delimiters.
# Use decimal value in ascii table (i.e. 59 = ';' and 10 = '\n' (line feed)).
; bar_delimiter = 59
; frame_delimiter = 10
# sdl window size and position. -1,-1 is centered.
; sdl_width = 1024
; sdl_height = 512
; sdl_x = -1
; sdl_y= -1
; sdl_full_screen = 0
# set label on bars on the x-axis. Can be 'frequency' or 'none'. Default: 'none'
# 'frequency' displays the lower cut off frequency of the bar above.
# Only supported on ncurses and noncurses output.
; xaxis = none
# enable synchronized sync. 1 = on, 0 = off
# removes flickering in alacritty terminal emulator.
# defaults to off since the behaviour in other terminal emulators is unknown
; synchronized_sync = 0
# Shaders for sdl_glsl, located in $HOME/.config/cava/shaders
; vertex_shader = pass_through.vert
; fragment_shader = bar_spectrum.frag
; for glsl output mode, keep rendering even if no audio
; continuous_rendering = 0
# disable console blank (screen saver) in tty
# (Not supported on FreeBSD)
; disable_blanking = 0
# show a flat bar at the bottom of the screen when idle, 1 = on, 0 = off
; show_idle_bar_heads = 1
# show waveform instead of frequency spectrum, 1 = on, 0 = off
; waveform = 0
[color]
# Colors can be one of seven predefined: black, blue, cyan, green, magenta, red, white, yellow.
# Or defined by hex code '#xxxxxx' (hex code must be within ''). User defined colors requires
# a terminal that can change color definitions such as Gnome-terminal or rxvt.
# default is to keep current terminal color
; background = default
; foreground = default
# SDL and sdl_glsl only support hex code colors, these are the default:
; background = '#111111'
; foreground = '#D34b11'
# ; foreground = '#D34b11'
# Gradient mode, only hex defined colors are supported,
# background must also be defined in hex or remain commented out. 1 = on, 0 = off.
# You can define as many as 8 different colors. They range from bottom to top of screen
gradient = 1
gradient_color_1 = '#d34b11'
gradient_color_2 = '#cc5933'
@@ -245,65 +17,3 @@ gradient_color_5 = '#cccc33'
gradient_color_6 = '#a6cc33'
gradient_color_7 = '#80cc33'
gradient_color_8 = '#59cc33'
# Horizontal is only supported on noncurses output.
# Only one color will be calculated per bar.
; horizontal_gradient = 1
; horizontal_gradient_color_1 = '#c45161'
; horizontal_gradient_color_2 = '#e094a0'
; horizontal_gradient_color_3 = '#f2b6c0'
; horizontal_gradient_color_4 = '#f2dde1'
; horizontal_gradient_color_5 = '#cbc7d8'
; horizontal_gradient_color_6 = '#8db7d2'
; horizontal_gradient_color_7 = '#5e62a9'
; horizontal_gradient_color_8 = '#434279'
# If both vertical and horizontal gradient is enabled, vertical will be blended in this direction.
# Can be 'up', 'down', 'left' or 'right'. 'up' means the vertical gradient will be blended in from
# bottom to top. I.e. the bottom will be only the horizontal
# and top will be only the color of the vertical gradient.
; blend_direction = 'up'
# use theme file instead of defining colors in this file
# themes are located in $HOME/.config/cava/themes
; theme = 'none'
[smoothing]
# Percentage value for integral smoothing. Takes values from 0 - 100.
# Higher values means smoother, but less precise. 0 to disable.
# DEPRECATED as of 0.8.0, use noise_reduction instead
; integral = 77
# Disables or enables the so-called "Monstercat smoothing" with or without "waves". Set to 0 to disable.
; monstercat = 0
; waves = 0
# Set gravity percentage for "drop off". Higher values means bars will drop faster.
# Accepts only non-negative values. 50 means half gravity, 200 means double. Set to 0 to disable "drop off".
# DEPRECATED as of 0.8.0, use noise_reduction instead
; gravity = 100
# In bar height, bars that would have been lower that this will not be drawn.
# DEPRECATED as of 0.8.0
; ignore = 0
# Noise reduction, int 0 - 100. default 77
# the raw visualization is very noisy, this factor adjusts the integral and gravity filters to keep the signal smooth
# 100 will be very slow and smooth, 0 will be fast but noisy.
; noise_reduction = 77
[eq]
# This one is tricky. You can have as much keys as you want.
# Remember to uncomment more than one key! More keys = more precision.
# Look at readme.md on github for further explanations and examples.
; 1 = 1 # bass
; 2 = 1
; 3 = 1 # midtone
; 4 = 1
; 5 = 1 # treble
@@ -1,79 +0,0 @@
#version 330
in vec2 fragCoord;
out vec4 fragColor;
// bar values. defaults to left channels first (low to high), then right (high to low).
uniform float bars[512];
uniform int bars_count; // number of bars (left + right) (configurable)
uniform int bar_width; // bar width (configurable), not used here
uniform int bar_spacing; // space bewteen bars (configurable)
uniform vec3 u_resolution; // window resolution
//colors, configurable in cava config file (r,g,b) (0.0 - 1.0)
uniform vec3 bg_color; // background color
uniform vec3 fg_color; // foreground color
uniform int gradient_count;
uniform vec3 gradient_colors[8]; // gradient colors
vec3 normalize_C(float y,vec3 col_1, vec3 col_2, float y_min, float y_max)
{
//create color based on fraction of this color and next color
float yr = (y - y_min) / (y_max - y_min);
return col_1 * (1.0 - yr) + col_2 * yr;
}
void main()
{
// find which bar to use based on where we are on the x axis
float x = u_resolution.x * fragCoord.x;
int bar = int(bars_count * fragCoord.x);
//calculate a bar size
float bar_size = u_resolution.x / bars_count;
//the y coordinate and bar values are the same
float y = bars[bar];
// make sure there is a thin line at bottom
if (y * u_resolution.y < 1.0)
{
y = 1.0 / u_resolution.y;
}
//draw the bar up to current height
if (y > fragCoord.y)
{
//make some space between bars basen on settings
if (x > (bar + 1) * (bar_size) - bar_spacing)
{
fragColor = vec4(bg_color,1.0);
}
else
{
if (gradient_count == 0)
{
fragColor = vec4(fg_color,1.0);
}
else
{
//find which color in the configured gradient we are at
int color = int((gradient_count - 1) * fragCoord.y);
//find where on y this and next color is supposed to be
float y_min = color / (gradient_count - 1.0);
float y_max = (color + 1.0) / (gradient_count - 1.0);
//make color
fragColor = vec4(normalize_C(fragCoord.y, gradient_colors[color], gradient_colors[color + 1], y_min, y_max), 1.0);
}
}
}
else
{
fragColor = vec4(bg_color,1.0);
}
}
-117
View File
@@ -1,117 +0,0 @@
#version 330
// this shader was stolen from shadertoy user ChunderFPV
#define SCALE 8.0
#define PI radians(180.0)
#define TAU (PI * 2.0)
#define CS(a) vec2(cos(a), sin(a))
#define PT(u, r) smoothstep(0.0, r, r - length(u))
in vec2 fragCoord;
out vec4 fragColor;
uniform float bars[512];
uniform int bars_count; // number of bars (left + right) (configurable)
uniform float shader_time; // shader execution time s
uniform int bar_width; // bar width (configurable), not used here
uniform int bar_spacing; // space bewteen bars (configurable)
uniform vec3 u_resolution; // window resolution
// colors, configurable in cava config file (r,g,b) (0.0 - 1.0)
uniform vec3 bg_color; // background color
uniform vec3 fg_color; // foreground color
uniform int gradient_count;
uniform vec3 gradient_colors[8]; // gradient colors
// gradient map ( color, equation, time, width, shadow, reciprocal )
vec3 gm(vec3 c, float n, float t, float w, float d, bool i) {
float g = min(abs(n), 1.0 / abs(n));
float s = abs(sin(n * PI - t));
if (i)
s = min(s, abs(sin(PI / n + t)));
return (1.0 - pow(abs(s), w)) * c * pow(g, d) * 6.0;
}
// denominator spiral, use 1/n for numerator
// ( screen xy, spiral exponent, decimal, line width, hardness, rotation )
float ds(vec2 u, float e, float n, float w, float h, float ro) {
float ur = length(u); // unit radius
float sr = pow(ur, e); // spiral radius
float a = round(sr) * n * TAU; // arc
vec2 xy = CS(a + ro) * ur; // xy coords
float l = PT(u - xy, w); // line
float s = mod(sr + 0.5, 1.0); // gradient smooth
s = min(s, 1.0 - s); // darken filter
return l * s * h;
}
void main() {
float t = shader_time / PI * 2.0;
vec4 m = vec4(0, 0, 0, 0); // iMouse;
m.xy = m.xy * 2.0 / u_resolution.xy - 1.0; // ±1x, ±1y
if (m.z > 0.0)
t += m.y * SCALE; // move time with mouse y
float z = (m.z > 0.0) ? pow(1.0 - abs(m.y), sign(m.y)) : 1.0; // zoom (+)
float e = (m.z > 0.0) ? pow(1.0 - abs(m.x), -sign(m.x))
: 1.0; // screen exponent (+)
float se = (m.z > 0.0) ? e * -sign(m.y) : 1.0; // spiral exponent
vec3 bg = vec3(0); // black background
float aa = 3.0; // anti-aliasing
for (float j = 0.0; j < aa; j++)
for (float k = 0.0; k < aa; k++) {
vec3 c = vec3(0);
vec2 o = vec2(j, k) / aa;
vec2 uv = (fragCoord * u_resolution.xy - 0.5 * u_resolution.xy + o) /
u_resolution.y * SCALE * z; // apply cartesian, scale and zoom
if (m.z > 0.0)
uv =
exp(log(abs(uv)) * e) * sign(uv); // warp screen space with exponent
float px = length(fwidth(uv)); // pixel width
float x = uv.x; // every pixel on x
float y = uv.y; // every pixel on y
float l = length(uv); // hypot of xy: sqrt(x*x+y*y)
float mc = (x * x + y * y - 1.0) / y; // metallic circle at xy
float g = min(abs(mc), 1.0 / abs(mc)); // gradient
vec3 gold = vec3(1.0, 0.6, 0.0) * g * l;
vec3 blue = vec3(0.3, 0.5, 0.9) * (1.0 - g);
vec3 rgb = max(gold, blue);
float w = 0.1; // line width
float d = 0.4; // shadow depth
c = max(c, gm(rgb, mc, -t, w * bars[0], d, false)); // metallic
c = max(c, gm(rgb, abs(y / x) * sign(y), -t, w * bars[1], d,
false)); // tangent
c = max(c, gm(rgb, (x * x) / (y * y) * sign(y), -t, w * bars[2], d,
false)); // sqrt cotangent
c = max(c, gm(rgb, (x * x) + (y * y), t, w * bars[3], d,
true)); // sqrt circles
c += rgb * ds(uv, se, t / TAU, px * 2.0 * bars[4], 2.0, 0.0); // spiral 1a
c += rgb * ds(uv, se, t / TAU, px * 2.0 * bars[5], 2.0, PI); // spiral 1b
c +=
rgb * ds(uv, -se, t / TAU, px * 2.0 * bars[6], 2.0, 0.0); // spiral 2a
c += rgb * ds(uv, -se, t / TAU, px * 2.0 * bars[7], 2.0, PI); // spiral 2b
c = max(c, 0.0); // clear negative color
c += pow(max(1.0 - l, 0.0), 3.0 / z); // center glow
if (m.z > 0.0) // display grid on click
{
vec2 xyg = abs(fract(uv + 0.5) - 0.5) / px; // xy grid
c.gb += 0.2 * (1.0 - min(min(xyg.x, xyg.y), 1.0));
}
bg += c;
}
bg /= aa * aa;
bg *= sqrt(bg) * 1.5;
fragColor = vec4(bg, 1.0);
}
@@ -1,34 +0,0 @@
#version 330
in vec2 fragCoord;
out vec4 fragColor;
// bar values. defaults to left channels first (low to high), then right (high to low).
uniform float bars[512];
uniform int bars_count; // number of bars (left + right) (configurable)
uniform vec3 u_resolution; // window resolution, not used here
//colors, configurable in cava config file
uniform vec3 bg_color; // background color(r,g,b) (0.0 - 1.0), not used here
uniform vec3 fg_color; // foreground color, not used here
void main()
{
// find which bar to use based on where we are on the x axis
int bar = int(bars_count * fragCoord.x);
float bar_y = 1.0 - abs((fragCoord.y - 0.5)) * 2.0;
float y = (bars[bar]) * bar_y;
float bar_x = (fragCoord.x - float(bar) / float(bars_count)) * bars_count;
float bar_r = 1.0 - abs((bar_x - 0.5)) * 2;
bar_r = bar_r * bar_r * 2;
// set color
fragColor.r = fg_color.x * y * bar_r;
fragColor.g = fg_color.y * y * bar_r;
fragColor.b = fg_color.z * y * bar_r;
}
@@ -1,14 +0,0 @@
#version 330
// Input vertex data, different for all executions of this shader.
layout(location = 0) in vec3 vertexPosition_modelspace;
// Output data ; will be interpolated for each fragment.
out vec2 fragCoord;
void main()
{
gl_Position = vec4(vertexPosition_modelspace,1);
fragCoord = (vertexPosition_modelspace.xy+vec2(1,1))/2.0;
}
@@ -1,53 +0,0 @@
#version 330
in vec2 fragCoord;
out vec4 fragColor;
// bar values. defaults to left channels first (low to high), then right (high
// to low).
uniform float bars[512];
uniform int bars_count; // number of bars (left + right) (configurable)
uniform int bar_width; // bar width (configurable), not used here
uniform int bar_spacing; // space bewteen bars (configurable)
uniform vec3 u_resolution; // window resolution
// colors, configurable in cava config file (r,g,b) (0.0 - 1.0)
uniform vec3 bg_color; // background color
uniform vec3 fg_color; // foreground color
uniform int gradient_count;
uniform vec3 gradient_colors[8]; // gradient colors
uniform sampler2D inputTexture; // Texture from the first render pass
vec3 normalize_C(float y, vec3 col_1, vec3 col_2, float y_min, float y_max) {
// create color based on fraction of this color and next color
float yr = (y - y_min) / (y_max - y_min);
return col_1 * (1.0 - yr) + col_2 * yr;
}
void main() {
// find which bar to use based on where we are on the y axis
int bar = int(bars_count * fragCoord.y);
float y = bars[bar];
float band_size = 1.0 / float(bars_count);
float current_band_min = bar * band_size;
float current_band_max = (bar + 1) * band_size;
int hist_length = 512;
float win_size = 1.0 / hist_length;
if (fragCoord.x > 1.0 - win_size) {
if (fragCoord.y > current_band_min && fragCoord.y < current_band_max) {
fragColor = vec4(fg_color * y, 1.0);
}
} else {
vec2 offsetCoord = fragCoord;
offsetCoord.x += float(win_size);
fragColor = texture(inputTexture, offsetCoord);
}
}
@@ -1,112 +0,0 @@
#version 330
// Emulate the "line style" spectrum analyzer from Winamp 2.
// Try this config for a demonstration:
/*
[general]
bar_width = 2
bar_spacing = 0
higher_cutoff_freq = 22000
[output]
method = sdl_glsl
channels = mono
fragment_shader = winamp_line_style_spectrum.frag
[color]
background = '#000000'
gradient = 1
gradient_color_1 = '#319C08'
gradient_color_2 = '#29CE10'
gradient_color_3 = '#BDDE29'
gradient_color_4 = '#DEA518'
gradient_color_5 = '#D66600'
gradient_color_6 = '#CE2910'
[smoothing]
noise_reduction = 10
*/
in vec2 fragCoord;
out vec4 fragColor;
// bar values. defaults to left channels first (low to high), then right (high to low).
uniform float bars[512];
uniform int bars_count; // number of bars (left + right) (configurable)
uniform int bar_width; // bar width (configurable), not used here
uniform int bar_spacing; // space bewteen bars (configurable)
uniform vec3 u_resolution; // window resolution
//colors, configurable in cava config file (r,g,b) (0.0 - 1.0)
uniform vec3 bg_color; // background color
uniform vec3 fg_color; // foreground color
uniform int gradient_count;
uniform vec3 gradient_colors[8]; // gradient colors
vec3 normalize_C(float y,vec3 col_1, vec3 col_2, float y_min, float y_max)
{
//create color based on fraction of this color and next color
float yr = (y - y_min) / (y_max - y_min);
return col_1 * (1.0 - yr) + col_2 * yr;
}
void main()
{
// find which bar to use based on where we are on the x axis
float x = u_resolution.x * fragCoord.x;
int bar = int(bars_count * fragCoord.x);
//calculate a bar size
float bar_size = u_resolution.x / bars_count;
//the y coordinate is stretched by 4X to resemble Winamp
float y = min(bars[bar] * 4.0, 1.0);
// make sure there is a thin line at bottom
if (y * u_resolution.y < 1.0)
{
y = 1.0 / u_resolution.y;
}
vec4 bar_color;
if (gradient_count == 0)
{
bar_color = vec4(fg_color,1.0);
}
else
{
//find color in the configured gradient for the top of the bar
int color = int((gradient_count - 1) * y);
//find where on y this and next color is supposed to be
float y_min = float(color) / (gradient_count - 1.0);
float y_max = float(color + 1) / (gradient_count - 1.0);
//make a solid color for the entire bar
bar_color = vec4(normalize_C(y, gradient_colors[color], gradient_colors[color + 1], y_min, y_max), 1.0);
}
//draw the bar up to current height
if (y > fragCoord.y)
{
//make some space between bars based on settings
if (x > (bar + 1) * (bar_size) - bar_spacing)
{
fragColor = vec4(bg_color,1.0);
}
else
{
fragColor = bar_color;
}
}
else
{
fragColor = vec4(bg_color,1.0);
}
}
-15
View File
@@ -1,15 +0,0 @@
[color]
background = '#001e26'
foreground = '#708183'
gradient = 1
gradient_color_1 = '#268bd2'
gradient_color_2 = '#6c71c4'
gradient_color_3 = '#cb4b16'
horizontal_gradient = 1
horizontal_gradient_color_1 = '#586e75'
horizontal_gradient_color_2 = '#b58900'
horizontal_gradient_color_3 = '#839496'
blend_direction = 'up'
-10
View File
@@ -1,10 +0,0 @@
[color]
horizontal_gradient = 1
horizontal_gradient_color_1 = '#c45161'
horizontal_gradient_color_2 = '#e094a0'
horizontal_gradient_color_3 = '#f2b6c0'
horizontal_gradient_color_4 = '#f2dde1'
horizontal_gradient_color_5 = '#cbc7d8'
horizontal_gradient_color_6 = '#8db7d2'
horizontal_gradient_color_7 = '#5e62a9'
horizontal_gradient_color_8 = '#434279'
+49 -19
View File
@@ -7,15 +7,20 @@ back into this repo.
## What's tracked here
- `settings.json` — global settings: permissions (deny git commit/push, `gh pr` write ops, and
reads of secret files), default `model`, `enabledPlugins`, vim mode, dark theme, `effortLevel`,
- `settings.json` — global settings: permissions (deny git push, `gh pr merge` / `close`,
and reads of secret files; ask before `gh pr create` and `git rebase`; allow `gh` reads, `gh pr edit`, `gh workflow run`
and `gh run rerun`; `git commit` is left to `hooks/git-guard.py`, which exempts listed repos), default `model`, `enabledPlugins`, vim mode, dark theme, `effortLevel`,
fullscreen TUI, `statusLine`, `hooks`, `worktree` defaults, a generic `autoMode.environment`, …
Nothing in here may name a specific project, path or remote: this file is published by the
public mirror, and the auto-mode classifier only reads `autoMode` from user/managed settings
anyway (never from a project's `.claude/settings*.json`).
- `statusline.py` — rich status line (dir, git, model, context %, cost, disk — system metrics live in the tmux bar).
- `statusline.py` — rich status line (dir, git, unresolved PR review threads, model, context %, cost, disk — system
metrics live in the tmux bar).
See [StatusBar.md](StatusBar.md) for what each segment and colour means.
- `keybindings.json` — custom keybindings: vim-style scroll/navigation, plus `ctrl+x v` for the
- `keybindings.json` — custom keybindings: vim-style scroll/navigation (`alt+j/k` and
`ctrl+shift+j/k` scroll by line — kitty's own `kitty_mod+j/k` scroll only its main-screen
scrollback and pass the key through to the fullscreen TUI's alternate screen, so they do not
shadow these; dropped once by mistake on 2026-09-15), plus `ctrl+x v` for the
verbose transcript view (`app:toggleTranscript`, same keystroke as opencode's `<leader>v`; the
default `ctrl+o` still works, and `ctrl+e` inside the view expands everything).
- `CLAUDE.md` — global user preferences applied to every project.
@@ -29,17 +34,32 @@ back into this repo.
installed, and always exits 0.
- `hooks/git-guard.py` — PreToolUse hook on `Bash`: structurally blocks anything that commits,
rewrites history or discards working-tree changes — `git commit` / `push` / `revert` /
`cherry-pick` / `rebase` / `merge` (not `--abort`), `clean` / `reset` in any form that moves HEAD
`cherry-pick` / `rebase -i|--autostash` / `merge` (not `--abort`), `clean` / `reset` in any form that moves HEAD
or unstages everything (`reset -- <paths>`, `reset HEAD <path>` stay allowed) / `restore <paths>`
(`--staged <path>` is fine, `--staged .` is not) / `checkout --` / `checkout .` / `stash drop|pop|clear` /
`branch -D|-f|-M`, and `gh pr merge` / `create` / `close` / `edit` — wherever the flags sit
`branch -D|-f|-M`, and `gh pr merge` / `close`, `gh pr create` without `--head` (gh would push
the branch itself) and `gh pr edit` beyond `--title` / `--body` — wherever the flags sit
(`git -C . push`, `/usr/bin/git push`, `sh -c 'git push'`), which the prefix-matched deny rules
can't. The discard group is what protects parallel sessions sharing one checkout. Exit 2 + a
can't. A plain `rebase` and its `--continue` / `--abort` pass: Claude rebases and fixes
conflicts when asked (`CLAUDE.md`), and settings.json asks before each `git rebase`. The
discard group is what protects parallel sessions sharing one checkout. Exit 2 + a
one-line reason. Heredoc bodies are skipped on purpose (file contents). Regression matrix:
`bash ~/.claude/hooks/git-guard-test.sh` (a file on purpose — typed inline, the commands under
test would trip the live hook).
test would trip the live hook). Repos listed in `~/.claude/git-guard-exempt` (host-local, not in
this repo — it names projects; one main checkout per line, worktrees included): `commit` and
`merge` pass there — judged from the hook input's
`cwd`, every `cd` before the call and `git -C` — except `commit --amend` / `-a`. A user-scope deny
cannot be lifted for one repo, so `git commit` is off the deny list: the hook is its only
structural block.
- `hooks/notify.py` — Notification hook (`idle_prompt|permission_prompt`): `notify-send` desktop
notification when Claude is waiting on you; no-op on a headless host.
- `../tmux/scripts/agent-state.sh` — UserPromptSubmit (busy) / Notification (input) / Stop (done) /
StopFailure (error) / SessionEnd (off) hooks, all async: marks the tmux pane this session runs in
(`@agent` pane option, drawn as ✓ ? ! after the window name by tmux.conf; visiting the pane
greys it out, the next prompt removes it) and buzzes the phone for a new mark on a pane not in
view (termux-notification, else a bell). Claude sessions run in daemon-spawned workers with no
tmux env, so the script finds the pane by the session name Claude puts in the pane title. Shared with codex
(`~/.codex/hooks.json`, untracked), pi and opencode. No-op outside tmux.
- `hooks/drift-check.sh` — SessionStart + UserPromptSubmit hook, read-only: fails loudly when a
`~/.config/claude/*` file has broken out of the repo into a plain file, or `~/.claude/*` no longer
resolves into the dots repo. That is what Claude Code's atomic settings rewrite does through the
@@ -55,14 +75,16 @@ back into this repo.
- `skills/` — custom [Agent Skills](https://code.claude.com/docs/en/skills). See **Skills** below.
- `link.sh` — idempotent bootstrap that creates the `~/.claude` symlinks below.
- `../git/claude-commit-msg` + `../git/hooks/prepare-commit-msg` — the commit-message handoff.
A session pipes its suggestion into `claude-commit-msg save`, which stores it as its own file under
`$(git rev-parse --git-path CLAUDE_COMMIT_MSG.d)/` **with the paths it staged**; on the next
`git commit` / lazygit `C` the hook prefills the editor with the message(s) whose paths are part of
that commit and consumes them (`used/`). Parallel sessions therefore never overwrite each other,
and committing one session's files gets that session's message. `claude-commit-msg list` shows
what is pending. The old single `CLAUDE_COMMIT_MSG` file is still honoured. Both tracked here
(deployed to `~/.config/git/`); the hook is installed **per repo** — see setup step 4 — and
deliberately not via a global `core.hooksPath`, which would override a project's own hooks.
A session pipes its suggestion into `claude-commit-msg save` (run inside the target repo), which
stores it as its own file under `$(git rev-parse --git-path CLAUDE_COMMIT_MSG.d)/` **with the paths
it staged**; on the next `git commit` / lazygit `C` the hook prefills the editor with the message(s)
whose paths are part of that commit and consumes them (`used/`). Parallel sessions therefore never
overwrite each other, and committing one session's files gets that session's message.
`claude-commit-msg list` shows what is pending. The old single `CLAUDE_COMMIT_MSG` file is still
honoured. Both tracked here (deployed to `~/.config/git/`); the hook is installed **per repo** — see
setup step 4 — and deliberately not via a global `core.hooksPath`, which would override a project's
own hooks. The prefill only runs for an editor commit: lazygit's `c` panel passes the message to git
directly, so the hook skips and the suggestion stays pending.
The `settings.json` hook commands for the newer scripts are wrapped in `[ -f … ]`, so a host that
has pulled the repo but not yet re-stowed just skips them instead of blocking every Bash call.
@@ -93,7 +115,8 @@ claude
```
```fish
# 4. In every repo you commit in: install the one-shot commit-message prefill hook
# 4. In every repo you commit in: install the commit-message prefill hook. New clones/inits get it
# from ~/.config/git/template (init.templateDir) once that template hook is a symlink to it too.
ln -sf ~/.config/git/hooks/prepare-commit-msg (git rev-parse --git-path hooks)/prepare-commit-msg
```
@@ -101,6 +124,12 @@ ln -sf ~/.config/git/hooks/prepare-commit-msg (git rev-parse --git-path hooks)/p
need to run `link.sh` by hand (`bash ~/.config/claude/link.sh`) if a link later gets clobbered
(e.g. Claude's `/config` replaces one with a plain file) — it's idempotent and self-healing.
Tracked *files* in `~/.claude` link straight at the repo copy, not through `~/.config/claude`:
Claude Code follows one symlink hop and then replaces the file atomically (temp + rename), so the
old two-hop chain lost its middle link to a plain file on every settings write (`/model`, effort,
plugins, `/config`) and the repo stopped seeing changes. With one hop the rename lands in the
checkout. Dirs (`hooks`, `skills`) still go through `~/.config/claude`.
A **new skill/hook** added to the repo shows up after a re-stow — `./install.sh` (or `dotsync`) links
the new file into `~/.config/claude`, which `link.sh` then surfaces into `~/.claude`.
@@ -122,8 +151,9 @@ so Claude never auto-triggers them. Run them inside the repo whose branch you're
copy-paste markdown block, and copies it to the clipboard with `wl-copy`. Leaves verification
checkboxes for you; never creates/pushes the PR.
- **`/commit-msg [hint]`** — reads the **staged** diff, infers the repo's commit style from recent
`git log` (e.g. this repo's `[Scope] summary`), drafts a matching message, prints it, and copies
it to the clipboard with `wl-copy`. Never stages or commits — draft only.
`git log` (e.g. this repo's `[Scope] summary`), drafts a matching message, prints it, and saves
it for the `prepare-commit-msg` hook (lazygit `C` prefills it). Never stages or commits — draft
only.
- **`/verbose [on|off|status]`** — flips the *persisted* verbose view for the current project:
`viewMode` (`verbose` / `default` — the key that actually decides the view and beats a sticky
`/focus` choice for new sessions) plus `verbose` and `showThinkingSummaries`, written to the
+10 -5
View File
@@ -8,13 +8,13 @@ resize), trailing (lowest-priority) segments are dropped from the right until it
Wide — everything:
```
📁 ~/.dots │ 🌿 .dots main* │ 🤖 Fable 5 (1M) · xhigh │ 📝 84% (843k) │ 📊 34% 2h54m │ 💰 $0.40 $12.00/h │ 🔢 ↑63M ↓595k │ ♻️ 91% 1h │ 💾 81%
📁 ~/.dots │ 🌿 .dots main* │ 💬 #42 3 · codex 2 │ 🤖 Fable 5 (1M) · xhigh │ 📝 84% (843k) │ 📊 34% 2h54m │ 💰 $0.40 $12.00/h │ 🔢 ↑63M ↓595k │ ⚡ 63 t/s │ ♻️ 91% 1h │ 💾 81%
```
Narrow — trailing segments trimmed:
```
📁 ~/.dots │ 🌿 .dots main* │ 🤖 Fable 5 (1M) · xhigh │ 📝 84% (843k) │ 📊 34% 2h54m │ 💰 $0.40 $12.00/h
📁 ~/.dots │ 🌿 .dots main* │ 💬 #42 3 · codex 2 │ 🤖 Fable 5 (1M) · xhigh │ 📝 84% (843k) │ 📊 34% 2h54m
```
Every segment is defensive: if its data is missing or a command fails, the segment is
@@ -28,11 +28,13 @@ is supplementary to the main colored value.
| | **Vim mode** | `[I]`/`[N]`/`[V]` editor mode | only with `editorMode: vim`; pair with `hideVimModeIndicator: true` so the built-in one doesn't duplicate it |
| 📁 | **Directory** | Current working dir, with `~` for home | cyan |
| 🌿 | **Git** | `repo branch` + `*` if dirty + `↑N`/`↓N` ahead/behind upstream | repo = magenta; branch = green when clean, yellow + red `*` when dirty; `↑` cyan, `↓` yellow. Omitted outside a repo |
| 💬 | **PR review threads** | `#N` of the current branch's open PR + its **unresolved review threads** (any author, outdated ones included; `+` past 100), Codex's share broken out as `· codex N`; green `✓` when none are left | A thread counts for whoever opened it (its first comment's author; Codex = login matching `codex`, as in `/pr-loop`). PR-conversation comments are not counted — they have no resolved state, so a summary or an LGTM would never let it reach zero. A GitHub round trip is ~0.7 s, so a render only reads the per-branch cache `~/.claude/cache/statusline.pr.<hash>.json` and, once it is older than 90 s, spawns a detached `statusline.py --pr-refresh` that updates it (one at a time, via a `.lock` claim) — new counts show on the next render. A failed fetch (no GitHub remote — e.g. this repo on gitea —, gh not authed, offline) backs off 10 min and keeps the last counts with a gray `?`. Omitted with no `gh`, no open PR or a detached HEAD |
| 🤖 | **Model** | Active model display name + `· effort` level (`low`/`medium`/`high`/`xhigh`) | name = blue, effort = light gray; effort omitted for models without the param |
| 📝 | **Context** | `% of context window used` + `(Nk)` tokens | usage comes from the payload's `context_window` (`used_percentage` + `current_usage`, Claude Code 2.1.x); the transcript's last `message.usage` is only a fallback. Window from `context_window.context_window_size`, then `(1M …)` in the display name, else 200k — **not** from `model.id`, which arrives with any `[1m]` suffix already stripped. Adds a red **⚠compact** at ≥80% |
| 📊 | **5h usage** | `% of the 5-hour rolling limit used` + time until it resets | from `rate_limits.five_hour`; Pro/Max only, and absent until the first API response of a session |
| 💰 | **Cost** | `$` session cost so far + `$/h` burn rate | burn rate shown once the session exceeds ~30s |
| 🔢 | **Session tokens** | `↑` input + `↓` output tokens used so far this session (cumulative, `k`/`M`) | summed over the transcript's assistant `message.usage` blocks, de-duplicated by message id (one line per content block shares the same usage). Input = `input + cache_creation + cache_read` — everything billed as input, so on a 1M session it dwarfs output (every call re-reads the cached prefix). Main transcript only: subagent usage lives in separate sidechain files. Incremental: a per-session state file `~/.claude/cache/statusline.tokens.<session>.json` remembers the byte offset already summed, so each refresh parses only the new tail (cold scan of a 6.5 MB transcript ≈ 70 ms, warm ≈ 0.1 ms); stale state files are pruned after 7 days once 50+ pile up. Omitted when there is no transcript; `↑` cyan, `↓` green, no thresholds |
| 🔢 | **Session tokens** | `↑` input + `↓` output tokens used so far this session (cumulative, `k`/`M`) | summed over the transcript's assistant `message.usage` blocks, de-duplicated by message id (one line per content block shares the same usage). Input = `input + cache_creation + cache_read` — everything billed as input, so on a 1M session it dwarfs output (every call re-reads the cached prefix). Main transcript only: subagent usage lives in separate sidechain files. Incremental: a per-session state file `~/.claude/cache/statusline.tokens.<session>.json` remembers the byte offset already summed, so each refresh parses only the new tail (warm ≈ 0.2 ms), at most 8 MiB per refresh (≈ 150 ms) — Claude Code kills a status script when the next refresh fires, so a cold scan of a huge resumed transcript is spread over a few refreshes (totals climb, then settle) instead of restarting from zero each time. Stale state files are pruned after 7 days once 50+ pile up. Omitted when there is no transcript; `↑` cyan, `↓` green, no thresholds |
| ⚡ | **Decode speed** | `N t/s` — tokens per second the model is generating | duration-weighted mean over the last 8 thinking blocks of the main conversation: `thinking_tokens / thinkingDurationMs` from the same transcript scan (blocks under 1 s or 50 tokens skipped). The only clean per-call timing Claude Code records: the payload's `cost.total_api_duration_ms` also absorbs subagent and advisor calls (it grew 28 s in a 12 s wall interval with no main-conversation completion) and its `output_tokens` is sometimes a mid-stream partial, so neither can give a rate. Includes first-token latency, so short blocks read a little low and long ones converge on the real decode speed (Fable 5.1 measured 60–85 t/s). Needs thinking enabled — omitted until a thinking block has completed, never shown with thinking off |
| ♻️ | **Prompt cache** | hit `%` (higher is better) + cache TTL (`5m` / `1h`), yellow `·cold` once it has expired | from the payload's `prompt_cache` (2.1.260+: `hit_ratio` 0–1, `ttl`, `warm`); computed from usage on older payloads. A `1h` TTL is what keeps a 10-min `/pr-loop` cadence warm |
| 💾 | **Disk** | `% used` of the filesystem at the cwd | from `statvfs` |
@@ -44,7 +46,9 @@ attention. Light gray = secondary detail. Thresholds (`green < … < yellow <
| Segment | green | yellow | red |
|---|---|---|---|
| Context | `< 50%` | `50–80%` | `≥ 80%` (⚠compact) |
| PR review threads | `✓` none unresolved | thread count | `codex N` |
| 5h usage | `< 50%` | `50–80%` | `≥ 80%` |
| Decode speed (inverted) | `≥ 40 t/s` | `20–40 t/s` | `< 20 t/s` |
| Prompt cache (inverted) | `≥ 80%` | `50–80%` | `< 50%` |
| Disk | `< 75%` | `75–90%` | `≥ 90%` |
@@ -74,5 +78,6 @@ events, so system metrics sat visibly stale between turns — the tmux status ba
## Where it lives
`statusline.py` is tracked in this repo and symlinked to `~/.claude/statusline.py`;
`settings.json` runs it via `statusLine` (`/usr/bin/env python3 ~/.claude/statusline.py`).
Edits take effect on the next status refresh.
`settings.json` runs it via `statusLine` (`/usr/bin/env python3 ~/.claude/statusline.py`) on every
message event and, through `refreshInterval: 60`, once a minute while the session idles — so the PR
count moves while you wait on a review. Edits take effect on the next status refresh.
+112 -10
View File
@@ -1,12 +1,24 @@
#!/usr/bin/env bash
# Regression matrix for git-guard.py: `bash ~/.claude/hooks/git-guard-test.sh` (exit 1 on mismatch).
# Kept as a file on purpose: typed inline, the very commands under test would trip the live hook.
G="$(dirname "$(readlink -f "$0")")/git-guard.py"; fail=0
G="$(dirname "$(readlink -f "$0")")/git-guard.py"
fail=0
# No exempt repository for the rows that do not ask for one, whatever this host's ~/.claude/git-guard-exempt says.
export GIT_GUARD_EXEMPT=""
# `git reset <one arg>` is allowed only for an existing path: give the matrix one to point at.
tmp=$(mktemp -d) && cd "$tmp" && touch existing.py
trap 'rm -rf "$tmp"' EXIT
run() { printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$2")}}" | python3 "$G" 2>/dev/null; rc=$?
got=allow; [ $rc -eq 2 ] && got=block; [ "$got" = "$1" ] && mark=ok || { mark=MISMATCH; fail=1; }; printf '%-8s %-6s %-6s %s\n' "$mark" "$1" "$got" "$(printf '%s' "$2" | head -1)"; }
run() {
printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$2")}}" | python3 "$G" 2>/dev/null
rc=$?
got=allow
[ $rc -eq 2 ] && got=block
[ "$got" = "$1" ] && mark=ok || {
mark=MISMATCH
fail=1
}
printf '%-8s %-6s %-6s %s\n' "$mark" "$1" "$got" "$(printf '%s' "$2" | head -1)"
}
echo "--- must block: commits / history ---"
run block 'git push'
run block 'git -C /home/coja/.dots push origin main'
@@ -28,7 +40,10 @@ run block 'git commit -m "fix: grep pattern; not a command"'
run block 'git revert HEAD'
run block 'git cherry-pick abc123'
run block 'git rebase -i HEAD~3'
run block 'git rebase main'
run block 'git rebase --interactive main'
run block 'git -C . rebase -i --autosquash main'
run block 'git rebase --autostash origin/main'
run block 'git -c rebase.autoStash=true rebase main'
run block 'git merge feature'
run block 'git merge --no-ff feature'
echo "--- must block: discards working-tree changes ---"
@@ -63,11 +78,16 @@ run block 'git stash clear'
run block 'git branch -D feature'
run block 'git branch -f main HEAD~1'
run block 'git branch -M main trunk'
echo "--- must block: gh pr write ops ---"
echo "--- must block: gh pr write ops (create only with --head, edit only title/body) ---"
run block 'gh pr merge 12 --squash'
run block 'gh -R o/r pr create --fill'
run block 'gh pr edit 3 --title x'
run block 'gh pr close 4'
run block 'gh -R o/r pr create --fill'
run block 'gh pr create --title x --body y'
run block 'gh pr create --dry-run --fill'
run block 'gh pr edit 3 --add-reviewer bob'
run block 'gh pr edit 3 --title x --base main'
run block 'gh pr edit --add-label=bug 3'
run block 'gh pr edit 3 -b y -m v2'
echo "--- must allow ---"
run allow 'git status'
run allow 'git log -1 --oneline'
@@ -88,6 +108,13 @@ run allow 'git checkout main'
run allow 'git checkout -b feature'
run allow 'git switch main'
run allow 'git merge --abort'
run allow 'git rebase main'
run allow 'git rebase origin/main'
run allow 'git rebase -Xtheirs main'
run allow 'git rebase --continue'
run allow 'git rebase --abort'
run allow 'git -c core.editor=true rebase --continue'
run allow 'GIT_EDITOR=true git rebase --continue'
run allow 'git stash'
run allow 'git stash list'
run allow 'git stash push -m wip'
@@ -110,12 +137,87 @@ run allow 'python3 - <<EOF
print("git commit is manual")
EOF'
run allow 'gh pr view 12 --json updatedAt'
run allow 'gh pr create --head feat --title x --body y'
run allow 'gh pr create -H feat --fill'
run allow 'gh -R o/r pr create --head=feat --fill'
run allow 'gh pr create --help'
run allow 'gh pr edit 3 --title x'
run allow 'gh pr edit 3 -t x -b "- item --base"'
run allow 'gh pr edit --body-file - 12'
run allow 'gh pr edit 12 -F body.md --repo o/r'
run allow 'gh pr edit 12 --title=x -bBody'
run allow 'gh workflow run ci.yml --ref main'
run allow 'gh run rerun 123 --failed'
run allow 'gh api graphql -f query="mutation{...}"'
run allow 'gh auth status'
run allow 'ls -la HEAD#x; git status'
run allow 'git log --grep="git push" -5'
run allow '~/.config/git/claude-commit-msg list'
echo "--- exempt repo (GIT_GUARD_EXEMPT = a scratch repo; commit and merge pass there, nothing else) ---"
# runin <expect> <cwd> <command>: the hook input carries the session's cwd, as Claude Code sends it.
R=$tmp/repo
WT=$tmp/repo-wt
git init -q "$R" && git -C "$R" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init \
&& git -C "$R" worktree add -q "$WT" 2>/dev/null && mkdir -p "$R/sub" && git init -q "$tmp/other"
runin() {
printf '%s' "$(python3 -c 'import json,sys; print(json.dumps({"tool_name":"Bash","tool_input":{"command":sys.argv[1]},"cwd":sys.argv[2]}))' "$3" "$2")" | GIT_GUARD_EXEMPT=$R python3 "$G" 2>/dev/null
rc=$?
got=allow
[ $rc -eq 2 ] && got=block
[ "$got" = "$1" ] && mark=ok || {
mark=MISMATCH
fail=1
}
printf '%-8s %-6s %-6s [%s] %s\n' "$mark" "$1" "$got" "${2#"$tmp"/}" "$(printf '%s' "$3" | head -1)"
}
runin allow "$R" 'git commit -m "docs: x"'
runin allow "$R" 'git commit -m x -- docs/usage.md'
runin allow "$R" 'git commit -m"fix a bug"'
runin allow "$R" 'git commit -m "-a is not the flag here"'
runin allow "$R" 'git commit --allow-empty -m x'
runin allow "$R/sub" 'git commit -m x'
runin allow "$R" 'git merge --ff-only feature'
runin allow "$R" 'git merge --no-ff feature'
runin allow "$WT" 'git commit -m x'
runin allow "$tmp" "git -C $WT commit -m x"
runin allow "$tmp" "cd $R && git commit -m x"
runin allow "$tmp" 'cd repo && git commit -m x'
runin allow "$tmp" "cd $WT; git status; git merge main"
runin allow "$R" "sh -c 'git commit -m x'"
runin block "$R" 'git push'
runin block "$R" 'git commit --amend --no-edit'
runin block "$R" 'git commit -am wip'
runin block "$R" 'git commit -qa -m wip'
runin block "$R" 'git commit --all -m wip'
runin block "$R" 'git rebase -i main'
runin block "$R" 'git cherry-pick abc123'
runin block "$R" 'git revert HEAD'
runin block "$R" 'git reset --hard HEAD~1'
runin block "$R" 'git checkout -- file.py'
runin block "$R" 'GIT_DIR=/x/.git git commit -m x'
runin block "$R" 'env GIT_WORK_TREE=/x git commit -m x'
runin block "$R" 'git --git-dir=/x/.git commit -m x'
runin block "$R" 'git -C /tmp commit -m x'
runin block "$R" 'git -C $(mktemp -d) commit -m x'
runin block "$tmp" "cd $R && cd /tmp && git commit -m x"
runin block "$R" 'cd - && git commit -m x'
runin block "$R" 'cd $HOME && git commit -m x'
runin block "$R" 'pushd /tmp && git commit -m x'
runin block "$tmp" 'git commit -m x'
runin block "$tmp/other" 'git commit -m x'
runin block "$tmp" "git -C $tmp/other merge x"
echo "--- fail-open ---"
printf '%s' '{"tool_name":"Edit","tool_input":{"command":"git push"}}' | python3 "$G"; [ $? -eq 0 ] && echo "ok non-Bash tool ignored" || { echo "MISMATCH non-Bash"; fail=1; }
printf 'not json' | python3 "$G"; [ $? -eq 0 ] && echo "ok garbage stdin passes" || { echo "MISMATCH garbage"; fail=1; }
[ $fail -eq 0 ] && echo "GIT-GUARD MATRIX: all ok" || { echo "GIT-GUARD MATRIX: mismatches above"; exit 1; }
printf '%s' '{"tool_name":"Edit","tool_input":{"command":"git push"}}' | python3 "$G"
[ $? -eq 0 ] && echo "ok non-Bash tool ignored" || {
echo "MISMATCH non-Bash"
fail=1
}
printf 'not json' | python3 "$G"
[ $? -eq 0 ] && echo "ok garbage stdin passes" || {
echo "MISMATCH garbage"
fail=1
}
[ $fail -eq 0 ] && echo "GIT-GUARD MATRIX: all ok" || {
echo "GIT-GUARD MATRIX: mismatches above"
exit 1
}
+179 -18
View File
@@ -7,15 +7,24 @@ command text, so `git -C . push`, `git -c k=v commit`, `/usr/bin/git push`,
This hook parses each shell segment instead: finds the git/gh token wherever it sits,
skips git's global options (`-C dir`, `-c k=v`, `--git-dir=...`) and blocks
git commit | push | revert | cherry-pick | rebase | merge (not --abort/--quit/--continue)
git commit | push | revert | cherry-pick | merge (not --abort/--quit/--continue)
git rebase -i / --interactive / --autostash
git clean | reset (any form that moves HEAD or unstages everything; `reset -- <paths>`,
`reset HEAD <path>` and `reset <existing path>` are fine) | restore <paths> (--staged
alone is fine, except `--staged .` / `:/`) | checkout -- / checkout . / -f
git stash drop | pop | clear git branch -D / -f / -M
gh pr merge | create | close | edit
gh pr merge | close | create without --head | edit beyond --title/--body
i.e. anything that commits, rewrites history, or discards working-tree changes -- the last
group matters most with several sessions sharing one checkout. Mirrors pi's permission-gate.
On GitHub Claude may open a PR (settings.json asks first) and retitle / redescribe one, nothing
more: `gh pr create` must name its branch with --head, without which gh pushes it itself (even
under --dry-run), and `gh pr edit` must not touch reviewers, labels, base or the like.
A plain `git rebase` (and its --continue / --abort) passes: Claude rebases and fixes the
conflicts when the user asks (global CLAUDE.md), and settings.json asks before each one, as
pi's gate does. `-i` stays blocked (the squash/reword tool, and it needs an editor), and so
does --autostash: it stashes and re-pops other sessions' uncommitted edits, where a plain
rebase refuses to start on a dirty tree.
Exit 2 + one stderr line = the call is blocked and Claude reads the reason (stage and
stop; /commit-msg drafts the message). Mirrors pi's permission-gate.ts.
@@ -31,15 +40,27 @@ README that documents pushing must stay writable -- so a script piped in through
is not inspected. Aliases (`git ci`) are not expanded. The deny list, the auto-mode
classifier and the global CLAUDE.md still cover those; this is the belt for the prefix
rules' braces. Fails open: any parse error lets the command through (exit 0).
Exempt repositories (one main checkout per line in ~/.claude/git-guard-exempt, host-local and
outside the dots repo on purpose: it names projects): there `git commit` and `git merge` pass
-- the user's decision for repos where Claude commits and merges itself. The repo is the one the call
actually targets: the hook input's `cwd`, moved by every `cd` before it in the command and by
`git -C`, resolved to its main checkout (so its worktrees count). Anything that hides the
target (`cd -`, `cd $VAR`, a substitution, `--git-dir`, `GIT_DIR=`) is judged not exempt.
`commit --amend` and `commit -a`/`--all` stay blocked there (they rewrite the last commit of
a shared checkout, or sweep other sessions' edits in), as do push, `rebase -i` and the discard
group. Known gap: a `cd` inside `( ... )` leaks into the commands after the parenthesis.
"""
import json
import os
import re
import shlex
import subprocess
import sys
GIT_BLOCK = {"commit", "push", "clean", "revert", "cherry-pick", "rebase"}
GIT_BLOCK = {"commit", "push", "clean", "revert", "cherry-pick"}
MERGE_OK = {"--abort", "--quit", "--continue"}
REBASE_BLOCK = {"-i", "--interactive", "--autostash"}
STASH_BLOCK = {"drop", "pop", "clear"}
BRANCH_FORCE = {"-D", "-f", "--force", "-M"}
RESET_MODES = {"--hard", "--soft", "--merge", "--keep"}
@@ -50,7 +71,20 @@ _HEX = re.compile(r"^[0-9a-f]{7,40}$")
def looks_like_ref(a):
return (a in ("HEAD", "ORIG_HEAD", "FETCH_HEAD", "MERGE_HEAD") or a.startswith("refs/")
or "~" in a or "^" in a or "@{" in a or bool(_HEX.match(a)))
GH_PR_BLOCK = {"merge", "create", "close", "edit"}
GH_PR_BLOCK = {"merge", "close"}
# `gh pr edit` may only retitle and redescribe; these take their value as the next word (or as
# `--title=x` / `-tx`).
GH_EDIT_OK = {"-t", "--title", "-b", "--body", "-F", "--body-file", "-R", "--repo"}
# Repositories (main checkouts; their worktrees count) where Claude commits and merges itself:
# one path per line, `#` comments; a missing file exempts nothing. GIT_GUARD_EXEMPT
# (colon-separated) replaces the file for the test matrix; it is read from the hook's own
# environment (Claude Code's), which a prefix on the checked command cannot reach.
EXEMPT_FILE = "~/.claude/git-guard-exempt"
EXEMPT_VERBS = {"commit", "merge"}
# `git commit` options whose value is the next word (or, for the short ones, the rest of the word).
COMMIT_ARG_SHORT = set("mFCct")
COMMIT_ARG_LONG = {"--message", "--file", "--author", "--date", "--reuse-message", "--reedit-message",
"--fixup", "--squash", "--template", "--trailer", "--cleanup", "--pathspec-from-file"}
# global options that take a *separate* argument; the `--opt=value` spelling is one token.
GIT_OPT_ARG = {"-C", "-c", "--git-dir", "--work-tree", "--exec-path", "--namespace",
"--super-prefix", "--config-env"}
@@ -72,13 +106,102 @@ def _skip_opts(args, with_arg):
return j
def git_reason(args):
def exempt_repos():
env = os.environ.get("GIT_GUARD_EXEMPT")
if env is not None:
paths = env.split(":")
else:
try:
with open(os.path.expanduser(EXEMPT_FILE)) as f:
paths = [ln.split("#", 1)[0].strip() for ln in f]
except OSError:
return set()
return {os.path.realpath(os.path.expanduser(p)) for p in paths if p}
def main_checkout(d):
"""The main checkout of the repository `d` lies in (a worktree resolves to it), or None."""
try:
out = subprocess.run(["git", "-C", d, "rev-parse", "--path-format=absolute", "--git-common-dir"],
capture_output=True, text=True, timeout=3)
except (OSError, subprocess.SubprocessError):
return None
common = out.stdout.strip().rstrip("/")
if out.returncode != 0 or not common:
return None
return os.path.realpath(os.path.dirname(common))
def in_exempt_repo(opts, cwd, pre):
"""Whether a git call with global options `opts`, run from `cwd` after the words `pre` of
its own command (`env X=1`, ...), targets an exempt repository."""
exempt = exempt_repos()
if not exempt or cwd is None or any(t.startswith(("GIT_DIR=", "GIT_WORK_TREE=", "GIT_COMMON_DIR="))
for t in pre):
return False
d, j = cwd, 0
while j < len(opts):
a = opts[j]
if a in ("--git-dir", "--work-tree") or a.startswith(("--git-dir=", "--work-tree=")):
return False
if a == "-C":
if j + 1 >= len(opts) or "SUBST" in opts[j + 1] or "$" in opts[j + 1]:
return False
d = os.path.join(d, os.path.expanduser(opts[j + 1]))
j += 2 if a in GIT_OPT_ARG else 1
return main_checkout(d) in exempt
def commit_rewrites_or_sweeps(rest):
"""`commit --amend` (rewrites the last commit) or `-a`/`--all` (takes in every modified
tracked file, other sessions' edits included)."""
skip = False
for a in rest:
if skip:
skip = False
continue
if a == "--":
break
if a in ("--amend", "--all"):
return True
if a in COMMIT_ARG_LONG:
skip = True
elif a.startswith("-") and not a.startswith("--"):
for k, ch in enumerate(a[1:], 1):
if ch == "a":
return True
if ch in COMMIT_ARG_SHORT:
skip = k == len(a) - 1 # the value is the next word
break
return False
def change_dir(cwd, args):
"""Where `cd args` leaves the shell started in `cwd`; None when that cannot be told."""
args = [a for a in args if a not in ("-L", "-P", "--")]
if not args:
return os.path.expanduser("~")
t = args[0]
if t == "-" or "SUBST" in t or "$" in t or (cwd is None and not t.startswith(("/", "~"))):
return None
return os.path.normpath(os.path.join(cwd or "/", os.path.expanduser(t)))
def git_reason(args, cwd=None, pre=()):
j = _skip_opts(args, GIT_OPT_ARG)
if j >= len(args):
return None
sub, rest = args[j], args[j + 1:]
if (sub in EXEMPT_VERBS and not (sub == "commit" and commit_rewrites_or_sweeps(rest))
and in_exempt_repo(args[:j], cwd, pre)):
return None
if sub in GIT_BLOCK:
return f"git {sub}"
if sub == "rebase":
flag = next((a for a in rest if a in REBASE_BLOCK), None)
if flag == "--autostash" or any(o.lower().startswith("rebase.autostash") for o in args[:j]):
return "git rebase --autostash (stashes other sessions' uncommitted edits)"
return f"git rebase {flag} (interactive history rewrite)" if flag else None
if sub == "reset":
opts = [a for a in rest if a.startswith("-") and a != "--"]
if RESET_MODES & set(opts):
@@ -109,14 +232,41 @@ def git_reason(args):
return None
def gh_edit_extra(rest):
"""The first `gh pr edit` option that is neither the title nor the body, else None."""
k = 0
while k < len(rest):
a = rest[k]
if a == "--":
break
name = a.split("=", 1)[0]
if name in GH_EDIT_OK:
k += 2 if name == a else 1 # skip the value, which may well start with `-`
continue
if a.startswith("-") and not (a[:2] in GH_EDIT_OK and not a.startswith("--")):
return a
k += 1
return None
def gh_reason(args):
j = _skip_opts(args, GH_OPT_ARG)
if j >= len(args) or args[j] != "pr":
return None
rest = args[j + 1:]
k = _skip_opts(rest, GH_OPT_ARG)
if k < len(rest) and rest[k] in GH_PR_BLOCK:
return f"gh pr {rest[k]}"
if k >= len(rest) or "--help" in rest:
return None
sub, rest = rest[k], rest[k + 1:]
if sub in GH_PR_BLOCK:
return f"gh pr {sub}"
if sub == "create" and not any(a in ("-H", "--head") or a.startswith("--head=")
or (a.startswith("-H") and not a.startswith("--")) for a in rest):
return "gh pr create without --head: gh would push the branch itself"
if sub == "edit":
extra = gh_edit_extra(rest)
if extra:
return f"gh pr edit {extra.split('=', 1)[0]}: only --title / --body edits are allowed"
return None
@@ -146,8 +296,8 @@ def segments(toks):
yield seg
def scan(text, depth=0):
"""Reason string for the first blocked invocation in `text`, else None."""
def scan(text, depth=0, cwd=None):
"""Reason string for the first blocked invocation in `text` (run from `cwd`), else None."""
if depth > 4:
return None
text = _HEREDOC.sub(" ", text)
@@ -163,23 +313,29 @@ def scan(text, depth=0):
while prev != text:
prev, text = text, _SUBST.sub(_grab, text)
for body in bodies:
r = scan(body, depth + 1)
r = scan(body, depth + 1, cwd)
if r:
return r
for toks in segments(tokenize(text)):
if toks[0] == "cd":
cwd = change_dir(cwd, toks[1:])
continue
if toks[0] in ("pushd", "popd"):
cwd = None
continue
for i, tok in enumerate(toks):
base = os.path.basename(tok)
r = None
if base == "git":
r = git_reason(toks[i + 1:])
r = git_reason(toks[i + 1:], cwd, toks[:i])
elif base == "gh":
r = gh_reason(toks[i + 1:])
elif base == "eval":
r = scan(" ".join(toks[i + 1:]), depth + 1)
r = scan(" ".join(toks[i + 1:]), depth + 1, cwd)
elif base in SHELLS and "-c" in toks[i + 1:]:
k = toks.index("-c", i + 1) # `sh -c '<cmd>'`: the real command is one word
if k + 1 < len(toks):
r = scan(toks[k + 1], depth + 1)
r = scan(toks[k + 1], depth + 1, cwd)
if r:
return r
return None
@@ -191,16 +347,21 @@ def main():
if data.get("tool_name", "Bash") != "Bash":
return
cmd = (data.get("tool_input") or {}).get("command") or ""
reason = scan(cmd) if cmd else None
reason = scan(cmd, 0, data.get("cwd") or os.getcwd()) if cmd else None
except Exception:
return # fail open: the deny list still applies
if reason:
short = cmd.strip().splitlines()[0]
short = short if len(short) <= 80 else short[:77] + "..."
sys.stderr.write(
f"git-guard: blocked `{short}` ({reason}). The user commits, pushes and merges "
"manually (global CLAUDE.md): stage and stop, and draft the message with /commit-msg.\n"
)
hint = ("On a PR Claude reads, opens one when asked (with --head <pushed branch>) and edits its "
"--title / --body; pushing, merging, closing and every other edit are the user's."
if reason.startswith("gh ") else
"Rebase with a plain `git rebase` on a clean tree, and only when the user asked; if the "
"tree holds other sessions' edits, tell the user instead of stashing them."
if reason.startswith("git rebase") else
"The user commits, pushes and merges manually (global CLAUDE.md): stage and stop, "
"and draft the message with /commit-msg.")
sys.stderr.write(f"git-guard: blocked `{short}` ({reason}). {hint}\n")
sys.exit(2)
+3 -1
View File
@@ -19,7 +19,9 @@
"context": "Global",
"bindings": {
"alt+j": "scroll:lineDown",
"alt+k": "scroll:lineUp"
"alt+k": "scroll:lineUp",
"ctrl+shift+j": "scroll:lineDown",
"ctrl+shift+k": "scroll:lineUp"
}
},
{
+14 -4
View File
@@ -41,6 +41,16 @@ items="settings.json statusline.py keybindings.json CLAUDE.md hooks skills"
# Expose each item in ~/.claude (where Claude actually reads).
for item in $items; do
[ -e "$SRC/$item" ] || continue
# Files link straight at the repo copy, not through ~/.config/claude: Claude Code
# resolves ONE symlink hop, then writes atomically (temp + rename) at that path —
# verified on 2.1.296 with `claude plugin disable` against a scratch CLAUDE_CONFIG_DIR.
# Through a ~/.claude -> ~/.config/claude -> repo chain, every settings write (/model,
# effort, plugins, /config) swapped the middle link for a plain file and severed the
# repo. One hop puts the rename inside the checkout, so both links survive. A SRC file
# that already drifted into a plain file keeps being the target (it may hold the newest
# edits) until the drift guard below is reconciled. Dirs keep the stow path.
target="$SRC/$item"
[ -f "$repo/$item" ] && [ -L "$SRC/$item" ] && target="$repo/$item"
if [ -d "$DST/$item" ] && [ ! -L "$DST/$item" ]; then
echo "skip ~/.claude/$item (real directory in the way — move it aside first)" >&2
continue
@@ -62,14 +72,14 @@ for item in $items; do
if [ "$same" != 1 ]; then
echo "DRIFT ~/.claude/$item is a real file that differs from the repo copy —" >&2
echo " it may hold newer edits. Review, then re-link:" >&2
echo " diff '$DST/$item' '$SRC/$item' # compare; keep whichever is right" >&2
echo " ln -sfn '$SRC/$item' '$DST/$item' # re-link" >&2
echo " diff '$DST/$item' '$target' # compare; keep whichever is right" >&2
echo " ln -sfn '$target' '$DST/$item' # re-link" >&2
continue
fi
echo "healed ~/.claude/$item (plain copy identical to repo — re-linking)"
fi
ln -sfn "$SRC/$item" "$DST/$item"
echo "linked ~/.claude/$item -> ~/.config/claude/$item"
ln -sfn "$target" "$DST/$item"
echo "linked ~/.claude/$item -> ${target/#$HOME/\~}"
done
# Stow-layer drift guard: each SRC *file* should be a symlink into the repo, so edits
+116 -8
View File
@@ -4,23 +4,60 @@
"pr": ""
},
"permissions": {
"allow": [
"Bash(cat:*)",
"Bash(head:*)",
"Bash(tail:*)",
"Bash(sed -n:*)",
"Bash(grep:*)",
"Bash(rg:*)",
"Bash(ls:*)",
"Bash(wc:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(git rev-parse:*)",
"Bash(git branch:*)",
"Bash(git stash list:*)",
"Bash(pnpm run:*)",
"Bash(pnpm exec:*)",
"Bash(node -p:*)",
"Bash(gh pr view:*)",
"Bash(gh pr list:*)",
"Bash(gh pr diff:*)",
"Bash(gh pr checks:*)",
"Bash(gh pr status:*)",
"Bash(gh issue view:*)",
"Bash(gh issue list:*)",
"Bash(gh issue status:*)",
"Bash(gh run view:*)",
"Bash(gh run list:*)",
"Bash(gh run watch:*)",
"Bash(gh workflow view:*)",
"Bash(gh workflow list:*)",
"Bash(gh repo view:*)",
"Bash(gh release view:*)",
"Bash(gh release list:*)",
"Bash(gh search:*)",
"Bash(gh label list:*)",
"Bash(gh status:*)",
"Bash(gh auth status:*)",
"Bash(gh pr edit:*)",
"Bash(gh workflow run:*)",
"Bash(gh run rerun:*)"
],
"deny": [
"Bash(git push)",
"Bash(git push:*)",
"Bash(git commit)",
"Bash(git commit:*)",
"Bash(git reset --hard)",
"Bash(git reset --hard:*)",
"Bash(git clean)",
"Bash(git clean:*)",
"Bash(gh pr merge)",
"Bash(gh pr merge:*)",
"Bash(gh pr create)",
"Bash(gh pr create:*)",
"Bash(gh pr close)",
"Bash(gh pr close:*)",
"Bash(gh pr edit)",
"Bash(gh pr edit:*)",
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/*.env)",
@@ -42,9 +79,15 @@
"Read(**/.gnupg/**)",
"Read(**/fish_variables)"
],
"ask": [
"Bash(gh pr create)",
"Bash(gh pr create:*)",
"Bash(git rebase)",
"Bash(git rebase:*)"
],
"defaultMode": "auto"
},
"model": "claude-fable-5-1[1m]",
"model": "opus",
"hooks": {
"SessionStart": [
{
@@ -67,6 +110,16 @@
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "f=\"$HOME/.config/tmux/scripts/agent-state.sh\"; [ -x \"$f\" ] && \"$f\" cc busy; exit 0",
"timeout": 5,
"async": true
}
]
}
],
"UserPromptExpansion": [
@@ -125,6 +178,51 @@
"timeout": 5
}
]
},
{
"matcher": "idle_prompt|permission_prompt",
"hooks": [
{
"type": "command",
"command": "f=\"$HOME/.config/tmux/scripts/agent-state.sh\"; [ -x \"$f\" ] && \"$f\" cc input; exit 0",
"timeout": 5,
"async": true
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "f=\"$HOME/.config/tmux/scripts/agent-state.sh\"; [ -x \"$f\" ] && \"$f\" cc done; exit 0",
"timeout": 5,
"async": true
}
]
}
],
"StopFailure": [
{
"hooks": [
{
"type": "command",
"command": "f=\"$HOME/.config/tmux/scripts/agent-state.sh\"; [ -x \"$f\" ] && \"$f\" cc error; exit 0",
"timeout": 5,
"async": true
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "f=\"$HOME/.config/tmux/scripts/agent-state.sh\"; [ -x \"$f\" ] && \"$f\" cc off; exit 0"
}
]
}
]
},
@@ -136,13 +234,23 @@
"type": "command",
"command": "/usr/bin/env python3 ~/.claude/statusline.py",
"padding": 0,
"refreshInterval": 60,
"hideVimModeIndicator": true
},
"enabledPlugins": {
"typescript-lsp@claude-plugins-official": true,
"frontend-design@claude-plugins-official": true
"frontend-design@claude-plugins-official": true,
"rust-analyzer-lsp@claude-plugins-official": true
},
"effortLevel": "xhigh",
"modelSettings": {
"claude-fable-5-1": {
"effortLevel": "xhigh"
},
"claude-opus-5-5": {
"effortLevel": "xhigh"
}
},
"advisorModel": "fable",
"tui": "fullscreen",
"skipWorkflowUsageWarning": true,
@@ -1,8 +1,8 @@
---
description: Draft a commit message for the staged changes, matching this repo's commit style, then print it in the reply, save it to the repo's CLAUDE_COMMIT_MSG file, and copy it to the clipboard. Never stages or commits. Use once you've staged what you want to commit.
description: Draft a commit message for the staged changes, matching this repo's commit style, then print it in the reply and save it to the repo's CLAUDE_COMMIT_MSG file. Never stages or commits. Use once you've staged what you want to commit.
argument-hint: [optional focus/scope hint]
disable-model-invocation: true
allowed-tools: Bash(git diff:*), Bash(git log:*), Bash(git status:*), Bash(git rev-parse:*), Bash(wl-copy:*), Bash(~/.config/git/claude-commit-msg:*), Read, Glob
allowed-tools: Bash(git diff:*), Bash(git log:*), Bash(git status:*), Bash(git rev-parse:*), Bash(~/.config/git/claude-commit-msg:*), Read, Glob
---
# Draft a commit message for the staged changes
@@ -26,9 +26,8 @@ Context for the current commit — what's staged, plus this repo's recent style:
- Add a short body (wrapped ~72 cols) only when the change needs the *why*; otherwise
subject-only. Don't invent motivation that isn't evident from the diff.
4. **Print the message in your reply first** — as a single fenced ` ```text ` block, copy-paste
ready. This in-reply copy is the durable one: it lives in the transcript, so it survives even if
the clipboard gets overwritten later (by me copying something else, or another Claude session
running `wl-copy`). Always print it; don't rely on the clipboard alone.
ready. This in-reply copy is the durable one: it lives in the transcript, so it survives even
after the hook has consumed the handoff. Always print it.
5. **Hand it off** so I can recover it any time — feed the raw message to the CLI as a quoted
heredoc (so quotes, backticks and `$` in the body survive untouched):
```sh
@@ -41,8 +40,6 @@ Context for the current commit — what's staged, plus this repo's recent style:
`prepare-commit-msg` hook prefills the next `git commit` / lazygit `C` with the message(s) whose
paths are part of that commit, then consumes them. Never write `CLAUDE_COMMIT_MSG` or
`COMMIT_EDITMSG` directly. The `save` output names the saved id and file path.
6. **Also copy it to the clipboard** as a convenience: `wl-copy < <file path printed by save>`.
Then tell me all three places it is: **printed above**, **saved for the hook** (with its id),
and **on the clipboard** — so I never lose it to a stray copy.
7. **Never** run `git add`, `git commit`, or `git push`, and never offer to — I stage and commit
Then tell me both places it is: **printed above** and **saved for the hook** (with its id).
6. **Never** run `git add`, `git commit`, or `git push`, and never offer to — I stage and commit
manually. Draft only.
@@ -2,7 +2,7 @@
description: Babysit the current branch's PR — address review comments that don't need my input, re-check every ~10 min, and stop when there are no open comments left or the automated reviewers (Codex, Claude) hit their limit. Prep-only — stages fixes and drafts the commit message; never commits, pushes, or merges.
argument-hint: [pr number or url — optional; defaults to the current branch's PR]
disable-model-invocation: true
allowed-tools: Bash(gh:*), Bash(git status:*), Bash(git diff:*), Bash(git log:*), Bash(git rev-parse:*), Bash(git fetch:*), Bash(git add:*), Bash(wl-copy:*), Bash(~/.config/git/claude-commit-msg:*), Read, Edit, Write, Grep, Glob, ScheduleWakeup
allowed-tools: Bash(gh:*), Bash(git status:*), Bash(git diff:*), Bash(git log:*), Bash(git rev-parse:*), Bash(git fetch:*), Bash(git add:*), Bash(~/.config/git/claude-commit-msg:*), Read, Edit, Write, Grep, Glob, ScheduleWakeup
---
# Watch this PR and resolve review comments (prep-only)
@@ -238,8 +238,7 @@ Current state (auth + the current branch's PR):
quoted heredoc, so quotes and backticks survive) — it is stored with the paths you staged this cycle, so the `prepare-commit-msg` hook prefills it only
for the commit that contains them (other sessions' handoffs are not overwritten). Never write
`CLAUDE_COMMIT_MSG` / `COMMIT_EDITMSG` directly.
- **(c) Copy it:** `wl-copy < <file path printed by save>`.
- Tell me all three locations. **Never** run `git commit`.
- Tell me both locations. **Never** run `git commit`.
(Skip this step when nothing new was staged this cycle.)
10. **Ping + schedule.** Give me a tight summary — deltas only, don't restate unchanged threads:
@@ -271,9 +270,10 @@ Current state (auth + the current branch's PR):
- **Never** `git commit`, `git push`, `gh pr merge`, `gh pr create`, `gh pr close`, or `gh pr edit`
— I do all of those. You stage, react, resolve, and draft; nothing more. The only comment you may
post is a needs-input decision question. This is enforced, not just asked: `settings.json` denies
those commands and the `git-guard.py` PreToolUse hook blocks any spelling of them, so a slip is
refused with a reason rather than executed.
post is a needs-input decision question. Commit, push, merge and close are enforced, not just
asked: `settings.json` denies them and the `git-guard.py` PreToolUse hook blocks any spelling of
them. `gh pr create` / `edit` are allowed globally (create asks first), so for this loop the rule
above is the only thing holding them back.
- Act only on the target PR. Don't touch unrelated files or other PRs.
- **Keep context lean:** no raw JSON dumps in replies, no re-listing threads that haven't changed,
idle cycles ≤2 lines. Every line you emit is re-read (uncached) on every later cycle.
+218 -31
View File
@@ -2,13 +2,15 @@
"""Claude Code status line.
One line: vim mode | dir (repo-relative, hidden at the root) |
git(repo/branch +dirty +ahead/behind) | model (+effort) |
git(repo/branch +dirty +ahead/behind) | PR review threads (open count, Codex's share) | model (+effort) |
context% (+compact warn) | 5h usage (+reset eta) | cost (+burn rate) |
session tokens (cumulative in/out) | prompt-cache hit% (+ttl, cold marker) | disk. If the
session tokens (cumulative in/out) | decode speed t/s (thinking blocks) |
prompt-cache hit% (+ttl, cold marker) | disk. If the
rendered line would overflow the terminal, trailing (lowest-priority) segments are
dropped until it fits.
(Dormant, re-addable in main(): ram, cpu%, temp -- retired 2026-07-26 because the
status line only refreshes on message events, so system metrics sat visibly stale;
status line then only refreshed on message events, so system metrics sat visibly stale
(settings.json's 60 s refreshInterval, added for the PR count, is still too coarse for them);
tmux's status bar owns those now -- plus velocity, cache hit %, api ratio, version,
style.)
@@ -16,6 +18,7 @@ Reads the status JSON from stdin (Claude Code statusLine command). Every segment
is wrapped defensively so the status line can never crash the UI -- on any error
a segment is simply omitted.
"""
import hashlib
import json
import os
import re
@@ -159,6 +162,9 @@ def last_usage(data):
# --- session token totals (transcript, incremental) ---------------------------
TOKENS_STATE_DIR = os.path.expanduser("~/.claude/cache")
TOKENS_RECENT_IDS = 20 # message ids remembered across runs for de-duplication
TPS_SAMPLES = 8 # thinking blocks the t/s readout is averaged over
TPS_MIN_MS, TPS_MIN_TOKENS = 1000, 50 # shorter blocks are first-token latency, not decode speed
TOKENS_SCAN_BUDGET = 8 << 20 # bytes parsed per refresh (~0.1 s); see session_tokens
TOKENS_STATE_MAX_AGE = 7 * 86400 # prune per-session state files older than this
@@ -183,9 +189,10 @@ def _tokens_state_path(data, path):
def _prune_tokens_state(keep):
"""Drop stale per-session state files (one scandir, only when the dir has piled up)."""
"""Drop stale per-session token and per-branch PR state files (one scandir, only when
the dir has piled up)."""
try:
entries = [e for e in os.scandir(TOKENS_STATE_DIR) if e.name.startswith("statusline.tokens.")]
entries = [e for e in os.scandir(TOKENS_STATE_DIR) if e.name.startswith(("statusline.tokens.", "statusline.pr."))]
if len(entries) < 50:
return
cutoff = time.time() - TOKENS_STATE_MAX_AGE
@@ -197,12 +204,21 @@ def _prune_tokens_state(keep):
def session_tokens(data):
"""Cumulative (input, output) tokens this session, summed over the transcript's
assistant `message.usage` blocks -- or None when there is no transcript.
"""Cumulative session token totals plus decode-speed samples, from the transcript --
`{"in", "out", "tps"}` or None when there is no transcript.
Input = input + cache_creation + cache_read (everything the API billed as input), so
on a 1M session it dwarfs output: every call re-reads the whole cached prefix.
Only the main transcript is counted; subagent usage lives in separate sidechain files.
in/out: summed over assistant `message.usage` blocks. Input = input + cache_creation +
cache_read (everything the API billed as input), so on a 1M session it dwarfs output:
every call re-reads the whole cached prefix. Only the main transcript is counted;
subagent usage lives in separate sidechain files.
tps: the last TPS_SAMPLES `[thinking_tokens, thinkingDurationMs]` pairs -- the only
clean per-call timing Claude Code records. The status payload's cost.total_api_duration_ms
is useless for a rate: subagent and advisor calls land in the same counter (it grew 28s
in a 12s wall interval with no main-conversation completion), and the payload's
output_tokens is sometimes a mid-stream partial. thinkingDurationMs is main-conversation
only; it includes first-token latency, so short blocks read low and long ones converge
on the real decode speed -- hence the duration-weighted mean and the minimum-size filter.
Incremental: the status line runs on every refresh and transcripts grow to tens of
MB, so a per-session state file (~/.claude/cache/statusline.tokens.<session>.json)
@@ -210,12 +226,18 @@ def session_tokens(data):
de-duplicated by message id: the transcript writes one line per content block of a
turn, all carrying the same id and the same usage block. Only complete lines are
consumed (the writer may be mid-line); a shrunken file (truncate/rotate) resets.
A cold scan is spread over refreshes, TOKENS_SCAN_BUDGET bytes each: Claude Code kills
a status script when the next refresh fires, and a multi-second scan of a 100 MB
transcript that never gets to write its state would start from zero every time.
Totals climb for a few refreshes on a huge resumed transcript, then settle.
"""
path = data.get("transcript_path")
if not path or not os.path.exists(path):
return None
state_path = _tokens_state_path(data, path)
state = {"path": path, "offset": 0, "in": 0, "out": 0, "ids": []}
fresh = {"path": path, "offset": 0, "in": 0, "out": 0, "ids": [], "tps": [], "tps_ids": []}
state = dict(fresh)
try:
with open(state_path) as fh:
s = json.load(fh)
@@ -225,27 +247,36 @@ def session_tokens(data):
and all(isinstance(s.get(k), int) and s[k] >= 0 for k in ("offset", "in", "out"))
and isinstance(s.get("ids"), list)
):
state = s
state = s # "tps"/"tps_ids" are optional: older state files predate them
except Exception:
pass
def result(st):
return {"in": st["in"], "out": st["out"], "tps": st.get("tps") or []}
size = os.path.getsize(path)
if size < state["offset"]: # truncated or rotated under us: start over
state = {"path": path, "offset": 0, "in": 0, "out": 0, "ids": []}
state = dict(fresh)
if size == state["offset"]:
return state["in"], state["out"]
return result(state)
with open(path, "rb") as fh:
fh.seek(state["offset"])
chunk = fh.read()
end = chunk.rfind(b"\n")
chunk = fh.read(TOKENS_SCAN_BUDGET)
end = chunk.rfind(b"\n")
if end < 0 and len(chunk) == TOKENS_SCAN_BUDGET: # one line bigger than the budget: finish it
chunk += fh.readline()
end = chunk.rfind(b"\n")
if end < 0: # no complete new line yet
return state["in"], state["out"]
return result(state)
text = chunk[: end + 1].decode("utf-8", "ignore")
tin, tout = state["in"], state["out"]
ids = [i for i in state["ids"] if isinstance(i, str)]
seen = set(ids)
tps = [s for s in (state.get("tps") or []) if isinstance(s, list) and len(s) == 2]
tps_ids = [i for i in (state.get("tps_ids") or []) if isinstance(i, str)]
tps_seen = set(tps_ids)
for line in text.splitlines():
if '"usage"' not in line:
continue
@@ -258,19 +289,38 @@ def session_tokens(data):
if not isinstance(usage, dict):
continue
mid = msg.get("id") or obj.get("requestId") or obj.get("uuid")
if not isinstance(mid, str) or mid in seen:
if not isinstance(mid, str):
continue
def num(d, k):
v = d.get(k, 0) if isinstance(d, dict) else 0
return int(v) if isinstance(v, (int, float)) else 0
# Decode-speed sample: checked on every line (not just the first-wins one below) so
# it does not depend on the thinking block being the message's first line.
ms = num(obj, "thinkingDurationMs")
toks = num(usage.get("output_tokens_details"), "thinking_tokens")
if ms >= TPS_MIN_MS and toks >= TPS_MIN_TOKENS and mid not in tps_seen:
tps_seen.add(mid)
tps_ids.append(mid)
tps.append([toks, ms])
if mid in seen:
continue
seen.add(mid)
ids.append(mid)
tin += num(usage, "input_tokens") + num(usage, "cache_creation_input_tokens") + num(usage, "cache_read_input_tokens")
tout += num(usage, "output_tokens")
def num(k):
v = usage.get(k, 0)
return int(v) if isinstance(v, (int, float)) else 0
tin += num("input_tokens") + num("cache_creation_input_tokens") + num("cache_read_input_tokens")
tout += num("output_tokens")
state = {"path": path, "offset": state["offset"] + end + 1, "in": tin, "out": tout, "ids": ids[-TOKENS_RECENT_IDS:]}
state = {
"path": path,
"offset": state["offset"] + end + 1,
"in": tin,
"out": tout,
"ids": ids[-TOKENS_RECENT_IDS:],
"tps": tps[-TPS_SAMPLES:],
"tps_ids": tps_ids[-TOKENS_RECENT_IDS:],
}
try:
os.makedirs(TOKENS_STATE_DIR, exist_ok=True)
tmp = f"{state_path}.{os.getpid()}.tmp"
@@ -280,7 +330,7 @@ def session_tokens(data):
_prune_tokens_state(state_path)
except Exception:
pass
return tin, tout
return result(state)
# --- row 1: work ------------------------------------------------------------
@@ -395,6 +445,122 @@ def git_segment(cwd, top):
return ""
# --- PR review threads (gh, cached, fetched in the background) ------------------
PR_TTL = 90 # seconds a fetched count stays fresh
PR_ERR_TTL = 600 # back-off after a failed fetch (no GitHub remote, gh not authed, offline)
PR_CLAIM = 30 # a refresher spawned this recently is still running: don't start another
PR_CODEX = re.compile(r"codex", re.I) # chatgpt-codex-connector; same match as /pr-loop
PR_QUERY = """query($owner:String!,$repo:String!,$branch:String!){
repository(owner:$owner,name:$repo){
pullRequests(headRefName:$branch,states:OPEN,first:1,orderBy:{field:UPDATED_AT,direction:DESC}){
nodes{ number reviewThreads(first:100){ pageInfo{hasNextPage}
nodes{ isResolved comments(first:1){ nodes{ author{login} } } } } } } } }"""
def _pr_cache_path(top, branch):
key = hashlib.sha1(f"{top}\0{branch}".encode()).hexdigest()[:16]
return os.path.join(TOKENS_STATE_DIR, f"statusline.pr.{key}.json")
def _read_json(path):
try:
with open(path) as fh:
d = json.load(fh)
return d if isinstance(d, dict) else {}
except Exception:
return {}
def pr_refresh(cwd, branch, path):
"""Background half of pr_segment (`statusline.py --pr-refresh CWD BRANCH CACHE`): fetch the
branch's open PR and write its unresolved review-thread counts to the cache. `{owner}` /
`{repo}` are gh placeholders resolved from the repo at `cwd`. A failed fetch keeps the last
counts and flags them stale; a branch without an open PR clears them."""
state = _read_json(path)
state["ts"] = time.time()
try:
r = subprocess.run(
["gh", "api", "graphql", "-F", "owner={owner}", "-F", "repo={repo}",
"-f", f"branch={branch}", "-f", f"query={PR_QUERY}"],
cwd=cwd, capture_output=True, text=True, timeout=20,
)
if r.returncode != 0:
raise RuntimeError(r.stderr)
nodes = json.loads(r.stdout)["data"]["repository"]["pullRequests"]["nodes"]
state = {"ts": state["ts"]}
if nodes:
threads = nodes[0]["reviewThreads"]
# a thread belongs to whoever opened it: its first comment's author
authors = [((t["comments"]["nodes"] or [{}])[0].get("author") or {}).get("login") or ""
for t in threads["nodes"] if not t["isResolved"]]
state.update(number=nodes[0]["number"], open=len(authors),
codex=sum(1 for a in authors if PR_CODEX.search(a)),
more=threads["pageInfo"]["hasNextPage"])
except Exception:
state["error"] = True
try:
os.makedirs(TOKENS_STATE_DIR, exist_ok=True)
tmp = f"{path}.{os.getpid()}.tmp"
with open(tmp, "w") as fh:
json.dump(state, fh)
os.replace(tmp, path)
except Exception:
pass
def pr_segment(cwd, top):
"""Unresolved review threads (any author, outdated ones included) on the current branch's
open PR: `💬 #N ✓` when none are left, else the count with Codex's share broken out.
A GitHub round trip takes ~0.7 s, too slow for a render, so this only reads a per-branch
cache (~/.claude/cache/statusline.pr.<hash>.json) and, once it is older than PR_TTL,
spawns a detached `--pr-refresh` child to update it -- the new count shows on the next
render. PR-conversation comments are not counted: they have no resolved state, so a
summary or an LGTM would keep the count from ever reaching zero.
"""
try:
if not top or not shutil.which("gh"):
return ""
branch = _git(cwd, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
if not branch or branch == "HEAD":
return ""
path = _pr_cache_path(top, branch)
state = _read_json(path)
if time.time() - (state.get("ts") or 0) > (PR_ERR_TTL if state.get("error") else PR_TTL):
claim = path + ".lock"
try:
claimed = time.time() - os.path.getmtime(claim) < PR_CLAIM
except OSError:
claimed = False
if not claimed:
os.makedirs(TOKENS_STATE_DIR, exist_ok=True)
with open(claim, "w"):
pass
# own session + no inherited stdout: Claude Code reads ours to EOF, and
# cancels an in-flight render when the next one triggers
subprocess.Popen(
[sys.executable, os.path.abspath(__file__), "--pr-refresh", cwd, branch, path],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True,
)
num = state.get("number")
if not num:
return ""
n, codex = state.get("open") or 0, state.get("codex") or 0
out = "💬 " + color(f"#{num} ", "gray")
if not n:
out += color("✓", "green")
else:
out += color(f"{n}{'+' if state.get('more') else ''}", "yellow", bold=True)
if codex:
out += color(" · ", "gray") + color(f"codex {codex}", "red")
if state.get("error"):
out += color(" ?", "gray") # last fetch failed: these counts are stale
return out
except Exception:
return ""
def model_segment(data):
name = (data.get("model") or {}).get("display_name")
if not name:
@@ -503,13 +669,12 @@ def velocity_segment(data):
return ""
def tokens_segment(data):
def tokens_segment(totals):
"""Cumulative session tokens: ↑ input (incl. cache reads/writes) and ↓ output."""
try:
totals = session_tokens(data)
if not totals:
return ""
tin, tout = totals
tin, tout = totals["in"], totals["out"]
if not (tin or tout):
return ""
return "🔢 " + color(f"↑{fmt_tokens(tin)}", "cyan") + " " + color(f"↓{fmt_tokens(tout)}", "green")
@@ -517,6 +682,22 @@ def tokens_segment(data):
return ""
def tps_segment(totals):
"""Decode speed in tokens/s: duration-weighted mean over the last few thinking blocks
(see session_tokens). Omitted until a thinking block ≥1s has completed -- so never
shown with thinking disabled."""
try:
samples = (totals or {}).get("tps") or []
toks = sum(s[0] for s in samples)
ms = sum(s[1] for s in samples)
if toks <= 0 or ms <= 0:
return ""
rate = toks / ms * 1000
return "⚡ " + color(f"{rate:.0f} t/s", bucket(rate, 20, 40, invert=True))
except Exception:
return ""
def cache_segment(usage, data=None):
"""Prompt-cache hit % (higher is better) + the cache TTL, `·cold` when the cache has
expired. Hit % from the 2.1.260+ payload `prompt_cache.hit_ratio` (a 0..1 fraction, null
@@ -697,6 +878,9 @@ def style_segment(data):
def main():
if sys.argv[1:2] == ["--pr-refresh"] and len(sys.argv) == 5:
pr_refresh(*sys.argv[2:])
return
try:
data = json.load(sys.stdin)
except Exception:
@@ -708,6 +892,7 @@ def main():
or os.getcwd()
)
usage = payload_usage(data) or last_usage(data)
totals = session_tokens(data)
top = git_top(cwd)
# One row: work (left) then system (right), joined. If it would overflow the
@@ -720,11 +905,13 @@ def main():
vim_segment(data),
dir_segment(cwd, top),
git_segment(cwd, top),
pr_segment(cwd, top),
model_segment(data),
context_segment(data, usage),
usage_segment(data),
cost_segment(data),
tokens_segment(data),
tokens_segment(totals),
tps_segment(totals),
cache_segment(usage, data),
) if s]
system = [s for s in (
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json",
"logo": {
"source": "$(find \"${XDG_CONFIG_HOME:-$HOME/.config}/fastfetch/pngs/\" -name \"*.png\" | shuf -n 1)",
"source": "$(find \"${XDG_CONFIG_HOME:-$HOME/.config}/fastfetch/pngs/\" -name \"*.png\" -xtype f | shuf -n 1)",
"height": 18
},
"display": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

@@ -0,0 +1,27 @@
# Mark the tmux window when a long shell command finishes, through the same
# ~/.config/tmux/scripts/agent-state.sh the coding agents use (tool tag "sh"):
# ✓ on exit 0, ! on failure — coloured when it happened while you were elsewhere, grey once
# visited, gone at the next command. Only commands longer than $agent_state_min_ms
# (default 30 s) count, and interactive programs are skipped: the agents mark themselves,
# and quitting a long nvim or lazygit session is not news. No-op outside tmux.
set -q agent_state_min_ms; or set -g agent_state_min_ms 30000
function __agent_state_preexec --on-event fish_preexec
set -q TMUX_PANE; or return
~/.config/tmux/scripts/agent-state.sh sh busy
end
function __agent_state_postexec --on-event fish_postexec
set -l st $status # first statement: anything before it would clobber the command's status
set -q TMUX_PANE; or return
test "$CMD_DURATION" -gt "$agent_state_min_ms"; or return
switch (string split -m1 ' ' -- $argv[1])[1]
case nvim vim vi less man lazygit ssh mosh tmux claude claudes pi opencode opencodes codex aichat htop btop top journalctl tail watch
return
end
if test $st -eq 0
~/.config/tmux/scripts/agent-state.sh sh done
else
~/.config/tmux/scripts/agent-state.sh sh error
end
end
@@ -0,0 +1,20 @@
# Drop kitty's KITTY_PUBLIC_KEY from the shell environment.
#
# kitty generates a random 40-char base85 public key per instance and exports it
# to every child (kitty/child.py). It is only read on the password path of kitty
# remote control (kitty/remote_control.py: "Password usage requested but
# KITTY_PUBLIC_KEY environment variable is not available"), which needs
# allow_remote_control — not enabled here. `kitten ssh`, icat, clipboard and
# shell integration never touch it.
#
# Why bother: tools that inline the environment into generated code get corrupted
# when a value contains a String.replace replacement sequence. prettier's
# --experimental-cli inlines JSON.stringify(process.env) into its parallel worker
# and splices it with .replace(placeholder, bootloader); a `$` + backtick in the
# key expands to "everything before the match", injecting the worker's own source
# mid-string. Every file then fails with a bogus
# "SyntaxError: Unexpected identifier 'string'". Roughly a 1-in-185 chance per
# kitty launch, which is exactly as fun to debug as it sounds.
if set -q KITTY_PUBLIC_KEY
set -e KITTY_PUBLIC_KEY
end
+1 -1
View File
@@ -106,7 +106,7 @@ if status is-interactive
abbr scrcpys 'scrcpy -wS --power-off-on-close'
abbr tts "tt -notheme -bold -showwpm -json"
abbr tuioss "tuios --show-clock --show-keys --show-cpu --show-ram --confirm-quit"
abbr keybinds "cd ~/.dots/ && nvim gui/.config/hypr/keybindings.conf"
abbr keybinds "cd ~/.dots/ && nvim gui/.config/hypr/dots_binds.lua"
abbr vpnhome "sudo wg-quick up wg0"
abbr vpnkralizec "sudo wg-quick up kralizec-wg0"
abbr vpnsumadija "sudo wg-quick up sumadija-wg0"
+6
View File
@@ -0,0 +1,6 @@
function glow --wraps glow --description 'glow wrapped at the current terminal width; an explicit -w still wins'
# glow never sizes itself to the terminal: glow.yml's width (or its built-in 80) applies, and
# `width: 0` in the config means 80 too. Wide tables get squeezed into that, one word per line.
# $COLUMNS tracks window resizes; a later -w/--width in $argv overrides this one.
command glow --width $COLUMNS $argv
end
+11
View File
@@ -0,0 +1,11 @@
function md2pdf --description 'Markdown to PDF next to the source: cmark-gfm renders it, headless chromium prints it'
# Relative image paths don't resolve (the HTML is rendered from /tmp), and chromium
# leaves out backgrounds when printing, so code blocks lose their grey box.
set -l html (mktemp --suffix=.html)
begin
echo '<meta charset="utf-8"><style>body{font-family:sans-serif;max-width:46em;margin:auto;line-height:1.5}table{border-collapse:collapse}td,th{border:1px solid #ccc;padding:.3em .6em}</style>'
cmark-gfm -e table -e strikethrough -e autolink -e tasklist $argv[1]
end >$html
chromium --headless --no-pdf-header-footer --print-to-pdf=(path change-extension pdf $argv[1]) $html 2>/dev/null
rm $html
end
+1 -1
View File
@@ -35,4 +35,4 @@ traceroute
fuck
atool
aria2c
cmark-gfm
+14 -14
View File
@@ -1,31 +1,31 @@
{
"LuaSnip": { "branch": "master", "commit": "0abc8f390b278c3b4aabc4c004ac8a088b65cf24" },
"NvChad": { "branch": "v2.5", "commit": "add44b952d631981614bbb8cfc6f7002f296dfe6" },
"base46": { "branch": "v3.0", "commit": "267954c8663607823f03a3259bb8deb15688212f" },
"NvChad": { "branch": "v2.5", "commit": "87d6a41ec07ab995de912a892749b6b4805ad2b3" },
"base46": { "branch": "v3.0", "commit": "d870ec89a7b2f28c4accce515f97b7bcc5b9d200" },
"cmp-async-path": { "branch": "main", "commit": "98185a91d49ff5dd249aebf2f7456e18063fa2a0" },
"cmp-buffer": { "branch": "main", "commit": "b74fab3656eea9de20a9b8116afa3cfc4ec09657" },
"cmp-nvim-lsp": { "branch": "main", "commit": "cbc7b02bb99fae35cb42f514762b89b5126651ef" },
"cmp-nvim-lua": { "branch": "main", "commit": "e3a22cb071eb9d6508a156306b102c45cd2d573d" },
"cmp_luasnip": { "branch": "master", "commit": "98d9cb5c2c38532bd9bdb481067b20fea8f32e90" },
"conform.nvim": { "branch": "master", "commit": "016802de402556da54c36bd7359b441266b01cdd" },
"friendly-snippets": { "branch": "main", "commit": "6cd7280adead7f586db6fccbd15d2cac7e2188b9" },
"gitsigns.nvim": { "branch": "main", "commit": "d29ecf6626cf6ffb109fb04a8ecd92c46f4a1ce9" },
"friendly-snippets": { "branch": "main", "commit": "b2ea5c41a150ba72fb656eedb4ed0a86d138d945" },
"gitsigns.nvim": { "branch": "main", "commit": "4bcd99167c265f135886b282baad77afe06206b2" },
"indent-blankline.nvim": { "branch": "master", "commit": "d28a3f70721c79e3c5f6693057ae929f3d9c0a03" },
"lazy.nvim": { "branch": "main", "commit": "306a05526ada86a7b30af95c5cc81ffba93fef97" },
"mason.nvim": { "branch": "main", "commit": "2a6940af80375532e5e9e7c1f2fc6319a1b7a69d" },
"menu": { "branch": "main", "commit": "7a0a4a2896b715c066cfbe320bdc048091874cc6" },
"minty": { "branch": "main", "commit": "aafc9e8e0afe6bf57580858a2849578d8d8db9e0" },
"nvim-autopairs": { "branch": "master", "commit": "7b9923abad60b903ece7c52940e1321d39eccc79" },
"nvim-cmp": { "branch": "main", "commit": "2ffe79f1f021def8dd1fcd81deb16f1bb0d989f3" },
"nvim-lspconfig": { "branch": "master", "commit": "4267d26e60323a45e18f8e7a63a964cc8f53ca4e" },
"nvim-tree.lua": { "branch": "master", "commit": "b2aadda94b107480c48e548d6db51c6840b7b33c" },
"nvim-treesitter": { "branch": "main", "commit": "c9f9ed6c1892f629ea399f4ee7905f2686fa13f2" },
"nvim-web-devicons": { "branch": "master", "commit": "2ae6958df7ced50baac5035cec0c15799eedfbf7" },
"nvim-autopairs": { "branch": "master", "commit": "3172f40c16563db871a58f20d205c710ad782311" },
"nvim-cmp": { "branch": "main", "commit": "e04302f1fefcb5653848346eb3723b39b82c549b" },
"nvim-lspconfig": { "branch": "master", "commit": "cc404d425c7752f56b8eaa269107be44d888ea51" },
"nvim-tree.lua": { "branch": "master", "commit": "69eb0c34ed3d836d5a0d92d913b77dd73b054f2f" },
"nvim-treesitter": { "branch": "main", "commit": "610734c5d3e59761d5f79318d120633374537fa5" },
"nvim-web-devicons": { "branch": "master", "commit": "ec309a0eaff6ea967e58c275996155ab5c16938d" },
"plenary.nvim": { "branch": "master", "commit": "74b06c6c75e4eeb3108ec01852001636d85a932b" },
"render-markdown.nvim": { "branch": "main", "commit": "f422cb5c6855f150e2ddcfaf44e7157b98b34f6a" },
"render-markdown.nvim": { "branch": "main", "commit": "8e8795368391036643b622875a6da6a51bcd33f4" },
"snacks.nvim": { "branch": "main", "commit": "882c996cf28183f4d63640de0b4c02ec886d01f2" },
"telescope.nvim": { "branch": "master", "commit": "427b576c16792edad01a92b89721d923c19ad60f" },
"ui": { "branch": "v3.0", "commit": "fe781d1c12860d6a25d45e588fe4fdd27eb34a1a" },
"volt": { "branch": "main", "commit": "620de1321f275ec9d80028c68d1b88b409c0c8b1" },
"telescope.nvim": { "branch": "master", "commit": "40aedd8a68c78a656a10a8d62d80c54af59420fb" },
"ui": { "branch": "v3.0", "commit": "8cbf0dc1883b20a1df114149c187d250f0af0853" },
"volt": { "branch": "main", "commit": "4351c5cd790a0d39491c4d5c75dd62d7db3d3414" },
"which-key.nvim": { "branch": "main", "commit": "3aab2147e74890957785941f0c1ad87d0a44c15a" }
}
+1
View File
@@ -15,6 +15,7 @@ service caches config).
| `agents/*.md` | Four subagents with real prompts and scoped permissions: `code-reviewer`, `test-runner` (hidden), `doc-writer` (edits docs only), `security-auditor` (hidden). |
| `commands/*.md` | `/commit` (Conventional Commits message, never commits) and `/review` (read-only diff review), both on the `plan` agent. |
| `plugins/verbose/` | 2.x TUI plugin: `<leader>v`, `/verbose`, or the palette entry toggles the *verbose view* — thinking blocks expanded and tool calls ungrouped — reading the live state from the built-in toggles so a mixed state converges. `server.ts` is a no-op half required by the loader. |
| `plugins/agent-state.ts` | Server-side plugin: mirrors the session state (`message.updated` user → busy, `permission.asked` → input, `session.idle` → done, `session.error` → error) into the tmux pane opencode runs in via `~/.config/tmux/scripts/agent-state.sh`, for the status-bar agent badges. Skips child sessions; only right when the TUI in the pane spawned the server (not `serve`/`attach`); under `opencodes` (bwrap) the tmux socket dir must be bound in. |
| `themes/catppuccin-mocha.json` | Catppuccin Mocha (dark) / Latte (light) theme following the pi theme's semantic mapping. Built-in `catppuccin` exists too; this one matches pi. |
| `tui.json` | TUI settings in the v1 schema (2.x compat): theme, leader timeout, stacked diffs, block cursor, attention notifications, a few extra keybinds. |
+2 -2
View File
@@ -151,7 +151,7 @@
"gemma-4-E4B-it-UD-Q8_K_XL": { "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs (~57 t/s, remote)", "attachment": true, "limit": { "context": 65536, "output": 16384 } },
"GLM-4.7-Flash-UD-Q4_K_XL": { "reasoning": true, "interleaved": "reasoning_content", "name": "GLM-4.7 Flash · 24k — quality coder (~21 t/s, remote)", "limit": { "context": 24576, "output": 8192 } },
"GLM-4.7-Flash-UD-Q3_K_XL": { "reasoning": true, "interleaved": "reasoning_content", "name": "GLM-4.7 Flash Q3 · 24k — fast coder (~28 t/s, remote)", "limit": { "context": 24576, "output": 8192 } },
"Ornith-1.5-35B-A3B-IQ3_XXS": { "reasoning": true, "name": "Ornith 1.5 35B · 24k · vision — agentic coder, thinking (~32 t/s, remote)", "attachment": true, "limit": { "context": 24576, "output": 8192 } },
"Ornith-1.5-35B-A3B-IQ3_XXS": { "reasoning": true, "name": "Ornith 1.5 35B · 32k · vision — agentic coder, thinking (~32 t/s, remote)", "attachment": true, "limit": { "context": 32768, "output": 8192 } },
"gpt-oss-20b": { "reasoning": true, "name": "gpt-oss 20B · 64k — fast reasoning + tools (~38 t/s, remote)", "limit": { "context": 65536, "output": 8192 } },
"gpt-oss-20b-low": { "reasoning": true, "name": "gpt-oss 20B low · 64k — snappy answers (~37 t/s, remote)", "limit": { "context": 65536, "output": 8192 } }
}
@@ -174,7 +174,7 @@
"gemma-4-E4B-it-UD-Q8_K_XL": { "name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs (~57 t/s)", "attachment": true, "limit": { "context": 65536, "output": 16384 } },
"GLM-4.7-Flash-UD-Q4_K_XL": { "reasoning": true, "interleaved": "reasoning_content", "name": "GLM-4.7 Flash · 24k — quality coder (~21 t/s)", "limit": { "context": 24576, "output": 8192 } },
"GLM-4.7-Flash-UD-Q3_K_XL": { "reasoning": true, "interleaved": "reasoning_content", "name": "GLM-4.7 Flash Q3 · 24k — fast coder (~28 t/s)", "limit": { "context": 24576, "output": 8192 } },
"Ornith-1.5-35B-A3B-IQ3_XXS": { "reasoning": true, "name": "Ornith 1.5 35B · 24k · vision — agentic coder, thinking (~32 t/s)", "attachment": true, "limit": { "context": 24576, "output": 8192 } },
"Ornith-1.5-35B-A3B-IQ3_XXS": { "reasoning": true, "name": "Ornith 1.5 35B · 32k · vision — agentic coder, thinking (~32 t/s)", "attachment": true, "limit": { "context": 32768, "output": 8192 } },
"gpt-oss-20b": { "reasoning": true, "name": "gpt-oss 20B · 64k — fast reasoning + tools (~38 t/s)", "limit": { "context": 65536, "output": 8192 } },
"gpt-oss-20b-low": { "reasoning": true, "name": "gpt-oss 20B low · 64k — snappy answers (~37 t/s)", "limit": { "context": 65536, "output": 8192 } }
}
@@ -0,0 +1,53 @@
// opencode agent-state plugin: mirrors the session state into the tmux pane opencode runs in via
// ~/.config/tmux/scripts/agent-state.sh (oc busy|done|input|error). No-op outside tmux.
//
// Runs in the SERVER process, so TMUX_PANE is only right when the TUI in the pane spawned the
// server (plain `opencode`), not for `opencode serve` / `opencode attach`. Under the bwrap
// `opencodes` wrapper the tmux socket dir must be bound in, or the script gives up quietly.
// Child (subagent) sessions are skipped so a subagent finishing does not flag "done".
import type { Plugin } from "@opencode-ai/plugin"
export const AgentState: Plugin = async ({ client, $ }) => {
const script = `${process.env.HOME}/.config/tmux/scripts/agent-state.sh`
const set = async (state: string) => {
if (!process.env.TMUX_PANE) return
try {
await $`${script} oc ${state}`.quiet().nothrow()
} catch {}
}
const childCache = new Map<string, boolean>()
const isChild = async (sessionID?: string) => {
if (!sessionID) return false
if (!childCache.has(sessionID)) {
try {
const r = await client.session.get({ path: { id: sessionID } })
childCache.set(sessionID, Boolean(r.data?.parentID))
} catch {
childCache.set(sessionID, false)
}
}
return childCache.get(sessionID) ?? false
}
return {
event: async ({ event }) => {
const p = (event as { properties?: Record<string, any> }).properties ?? {}
switch (event.type) {
case "message.updated": // a new user message = a prompt went in
if (p.info?.role === "user" && !(await isChild(p.info?.sessionID))) await set("busy")
break
case "permission.asked":
if (!(await isChild(p.sessionID))) await set("input")
break
case "permission.replied":
if (!(await isChild(p.sessionID))) await set("busy")
break
case "session.idle":
if (!(await isChild(p.sessionID))) await set("done")
break
case "session.error":
if (!(await isChild(p.sessionID))) await set("error")
break
}
},
}
}
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# agent-next.sh: switch to the next window (after the current one, wrapping) that holds an
# agent mark you have not looked at yet; with none left, fall back to any grey mark; with
# nothing marked, say so. Bound to prefix+Tab and to the » tap target in the status bar.
cur=$(tmux display -p '#{window_index}')
pick() { # $1 empty = unseen marks only, "any" = grey ones too
tmux list-panes -a -F '#{window_index} #{@agent} #{@agent_seen}' |
awk -v c="$cur" -v any="$1" '
$2 != "" && $2 != "busy" && (any != "" || $3 == "") { if ($1 > c && !n) n = $1; if (!f) f = $1 }
END { if (n) print n; else if (f) print f }'
}
w=$(pick); [ -n "$w" ] || w=$(pick any)
if [ -n "$w" ]; then tmux select-window -t ":$w"; else tmux display-message "no agent waiting"; fi
+69
View File
@@ -0,0 +1,69 @@
#!/bin/sh
# agent-state.sh <cc|oc|pi|cx> <busy|done|input|error|off>
#
# Per-pane "what is the agent in this pane doing" for tmux, shared by every agent's hooks:
# Claude Code (claude/settings.json), codex (~/.codex/hooks.json), pi (agent-state.ts
# extension) and opencode (plugins/agent-state.ts). No-op outside tmux.
#
# Which pane: $TMUX_PANE when the agent runs in the pane itself (pi, opencode, codex). Claude
# Code sessions run in a worker the claude daemon pre-spawned ("claude bg-spare"), whose
# environment has no tmux variables, so for them the worker is found by walking up the parent
# chain to the pid registered in ~/.claude/sessions/<pid>.json, and the pane is the one whose
# title Claude set to that session's name ("✳ <name>").
#
# Pane options read by tmux.conf's @agent_badge: @agent (the state, drawn as ✓ ? ! after the
# window name; busy draws nothing), @agent_seen (set by tmux's pane-focus-in hook: the glyph
# loses its colour once you visit the pane and stays, grey, until the next prompt turns the
# state busy) and @agent_tool (only the termux notification title uses it). `off` unsets all.
#
# Phone-level notice only for a NEW mark on a pane that is not in view: termux-notification
# when the agent runs natively in termux, otherwise a BEL into the pane (tmux forwards the bell
# to the outer terminal; termux's default bell-character=vibrate buzzes, and the window gets
# tmux's bell flag). A state change on an already-marked pane (e.g. Claude's idle_prompt ~60s
# after Stop turning done into input) only swaps the glyph: no second buzz, seen stays as is.
command -v tmux >/dev/null 2>&1 || exit 0
tool=$1 state=$2 pane=$TMUX_PANE
if [ -z "$pane" ]; then
p=$$ worker=""
for _ in 1 2 3 4 5 6 7 8; do
p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d ' ')
[ -n "$p" ] && [ "$p" != 1 ] || break
[ -f "$HOME/.claude/sessions/$p.json" ] && { worker=$p; break; }
done
[ -n "$worker" ] || exit 0
name=$(python3 -I -c 'import json,sys; print(json.load(open(sys.argv[1])).get("name",""))' \
"$HOME/.claude/sessions/$worker.json" 2>/dev/null)
[ -n "$name" ] || exit 0
pane=$(tmux list-panes -a -F '#{pane_id} #{pane_title}' 2>/dev/null |
awk -F'\t' -v n="$name" '$2==n || substr($2,length($2)-length(n))==" " n {print $1; exit}')
[ -n "$pane" ] || exit 0
fi
if [ "$state" = off ]; then
for o in @agent @agent_seen @agent_tool; do tmux set -p -t "$pane" -u "$o" 2>/dev/null; done
exit 0
fi
tmux set -p -t "$pane" @agent_tool "$tool" 2>/dev/null || exit 0 # no server reachable (sandbox): give up quietly
case $state in
busy)
tmux set -p -t "$pane" @agent busy
tmux set -p -t "$pane" -u @agent_seen 2>/dev/null
command -v termux-notification-remove >/dev/null 2>&1 && termux-notification-remove "agent-$pane"
;;
*)
already=$(tmux show -pv -t "$pane" @agent 2>/dev/null)
tmux set -p -t "$pane" @agent "$state"
if [ "$(tmux display -p -t "$pane" '#{pane_active}#{window_active}')" = 11 ]; then
tmux set -p -t "$pane" @agent_seen 1 # in view: you saw it happen, so it starts out seen
exit 0
fi
[ -n "$already" ] && [ "$already" != busy ] && exit 0 # glyph swapped on an existing mark
tmux set -p -t "$pane" -u @agent_seen 2>/dev/null
if command -v termux-notification >/dev/null 2>&1; then
termux-notification --id "agent-$pane" --title "$tool: $state" --content "${PWD##*/}" \
--vibrate 200 --action "tmux select-pane -t $pane"
else
printf '\a' > "$(tmux display -p -t "$pane" '#{pane_tty}')" 2>/dev/null
fi
;;
esac
exit 0
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
# CPU utilisation (%) averaged over the interval since the PREVIOUS status refresh:
# /proc/stat totals persist in a state file between runs (same pattern as net.sh),
# so the value covers the whole status-interval. (The old version slept 0.3s and
# sampled just that window — a 6% duty cycle that aliased bursty loads.) No sleep,
# ~5ms. First run (no state yet) shows the since-boot average. Glyph = Waybar's
# cpu module icon (U+F035B).
printf '\U000f035b '
rt="${XDG_RUNTIME_DIR:-/tmp}"
st="$rt/tmux_cpu_stat" # previous totals: tot idle
read -r _ u n s i w rest </proc/stat
idle=$((i + w))
tot=$((u + n + s + i + w))
for v in $rest; do tot=$((tot + v)); done
p_tot=0
p_idle=0
[ -r "$st" ] && read -r p_tot p_idle <"$st"
[ "$p_tot" -gt "$tot" ] && {
p_tot=0
p_idle=0
} # stale state from a previous boot
printf '%s %s\n' "$tot" "$idle" >"$st"
dt=$((tot - p_tot))
di=$((idle - p_idle))
if [ "$dt" -le 0 ]; then printf -- '--%%'; else printf '%d%%' $(((100 * (dt - di) + dt / 2) / dt)); fi
-5
View File
@@ -1,5 +0,0 @@
#!/usr/bin/env bash
# RAM utilisation (%) — used = MemTotal - MemAvailable, from /proc/meminfo.
# Called from tmux status-right via #(); cached per status-interval.
printf '\U000f0f86 ' # Waybar's memory glyph (U+F0F86), then the percentage
awk '/^MemTotal:/{t=$2} /^MemAvailable:/{a=$2} END{if(t>0)printf "%d%%",(t-a)*100/t; else printf "--%%"}' /proc/meminfo
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# metrics.sh full|compact — the status-right system segment in ONE process per refresh.
# (tmux used to spawn net dl, net ul, cpu, mem and temp as five separate #() jobs per tick.)
# Prints #[fg=…] style markers, which tmux honours inside #() output.
# full : net dl · net ul · cpu · mem · temp (temp vanishes when no sensor exists)
# compact : cpu · mem
# CPU and net are averaged over the interval since the PREVIOUS refresh: the /proc totals
# persist in $XDG_RUNTIME_DIR state files, so no sleeps and the value covers the whole
# status-interval. First run (no state yet) shows the since-boot average. Glyphs are Waybar's:
# cpu U+F035B, memory U+F0F86, thermometer U+F050F, download U+F019, upload U+F093.
mode=${1:-full}
rt="${XDG_RUNTIME_DIR:-/tmp}"
cpu() { # utilisation % since the previous refresh
local st="$rt/tmux_cpu_stat" u n s i w rest idle tot v p_tot=0 p_idle=0 dt di
read -r _ u n s i w rest </proc/stat
idle=$((i + w))
tot=$((u + n + s + i + w))
for v in $rest; do tot=$((tot + v)); done
[ -r "$st" ] && read -r p_tot p_idle <"$st"
[ "$p_tot" -gt "$tot" ] && { p_tot=0; p_idle=0; } # stale state from a previous boot
printf '%s %s\n' "$tot" "$idle" >"$st"
dt=$((tot - p_tot))
di=$((idle - p_idle))
printf '\U000f035b '
if [ "$dt" -le 0 ]; then printf -- '--%%'; else printf '%d%%' $(((100 * (dt - di) + dt / 2) / dt)); fi
}
mem() { # used = MemTotal - MemAvailable
printf '\U000f0f86 '
awk '/^MemTotal:/{t=$2} /^MemAvailable:/{a=$2} END{if(t>0)printf "%d%%",(t-a)*100/t; else printf "--%%"}' /proc/meminfo
}
temp() { # CPU °C: a CPU-ish thermal zone, else a CPU hwmon chip, else the hottest zone; nothing if none
local cpu_zone="" any_zone="" hw="" d t ty f
for d in /sys/class/thermal/thermal_zone*/; do
[ -r "${d}temp" ] && [ -r "${d}type" ] || continue
t=$(<"${d}temp")
case $t in '' | *[!0-9]*) continue ;; esac
ty=$(<"${d}type"); ty=${ty,,}
case $ty in *x86_pkg_temp* | *coretemp* | *k10temp* | *tctl* | *cpu*)
if [ -z "$cpu_zone" ] || [ "$t" -gt "$cpu_zone" ]; then cpu_zone=$t; fi ;;
esac
if [ -z "$any_zone" ] || [ "$t" -gt "$any_zone" ]; then any_zone=$t; fi
done
if [ -z "$cpu_zone" ]; then
for d in /sys/class/hwmon/hwmon*/; do
[ -r "${d}name" ] || continue
case $(<"${d}name") in coretemp | k10temp | zenpower | cpu_thermal)
for f in "${d}"temp*_input; do
[ -r "$f" ] || continue
t=$(<"$f")
case $t in '' | *[!0-9]*) continue ;; esac
if [ -z "$hw" ] || [ "$t" -gt "$hw" ]; then hw=$t; fi
done ;;
esac
done
fi
t=${cpu_zone:-${hw:-$any_zone}}
[ -n "$t" ] || return 0
printf '\U000f050f %d° ' $(((t + 500) / 1000))
}
human() { awk -v b="$1" 'BEGIN{ split("B K M G T", u, " "); i=1;
while (b >= 1024 && i < 5) { b /= 1024; i++ }
if (i == 1) printf "%d%s", b, u[i]; else printf "%.1f%s", b, u[i] }'; }
net() { # sets dl/ul: bytes per second over all non-lo interfaces since the previous refresh
local ctr="$rt/tmux_net_ctr" now rx tx prx ptx pnow drx=0 dtx=0 dt=1
now=$(date +%s.%N)
read -r rx tx < <(awk 'NR>2{gsub(/:/," "); if($1!="lo"){r+=$2; t+=$10}} END{print r+0, t+0}' /proc/net/dev)
if [ -r "$ctr" ]; then
read -r prx ptx pnow <"$ctr"
dt=$(awk -v a="$now" -v b="$pnow" 'BEGIN{d=a-b; print (d>0.05)?d:1}')
drx=$((rx - prx)); dtx=$((tx - ptx))
[ "$drx" -lt 0 ] && drx=0
[ "$dtx" -lt 0 ] && dtx=0
fi
printf '%s %s %s\n' "$rx" "$tx" "$now" >"$ctr"
dl=$(human "$(awk -v x="$drx" -v d="$dt" 'BEGIN{printf "%d", x/d}')")
ul=$(human "$(awk -v x="$dtx" -v d="$dt" 'BEGIN{printf "%d", x/d}')")
}
case $mode in
compact)
printf '#[fg=#89b4fa]%s #[fg=#94e2d5]%s' "$(cpu)" "$(mem)"
;;
*)
net
printf '#[fg=#a6e3a1]\U0000f019 %s #[fg=#f9e2af]\U0000f093 %s #[fg=#89b4fa]%s #[fg=#94e2d5]%s #[fg=#fab387]%s' \
"$dl" "$ul" "$(cpu)" "$(mem)" "$(temp)"
;;
esac
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env bash
# Network throughput from /proc/net/dev (sum of all non-lo interfaces), averaged
# over the interval between status refreshes — no blocking sleep.
#
# Usage: net.sh dl -> " 1.2M" (download icon U+F019 + rate)
# net.sh ul -> " 45K" (upload icon U+F093 + rate)
#
# tmux calls both from status-right. A short (<3s) cache lets the two calls of a
# single refresh share one computation, so the up/down figures are consistent and
# only one of them re-samples the counters per refresh.
which=${1:-dl}
rt="${XDG_RUNTIME_DIR:-/tmp}"
ctr="$rt/tmux_net_ctr" # previous counters: rx tx epoch.ns
cache="$rt/tmux_net_cache" # computed rates: dl_rate ul_rate epoch_s
emit() { # emit <icon-printf-escape> <rate>
printf "$1 %s" "$2"
}
# Reuse a fresh cache so dl and ul agree and we only sample once per refresh.
fresh_emit() { # print from the cache and exit — if the cache is fresh enough
[ -r "$cache" ] || return 1
read -r cdl cul cts <"$cache"
{ [ -n "$cts" ] && [ "$(($(date +%s) - cts))" -lt 3 ]; } || return 1
if [ "$which" = ul ]; then emit '\U0000f093' "$cul"; else emit '\U0000f019' "$cdl"; fi
exit 0
}
fresh_emit
# tmux launches the dl and ul jobs CONCURRENTLY each refresh, so without a lock
# both miss the stale cache and compute independently — worst case the loser reads
# the counters milliseconds after the winner rewrote them and shows a ~0 rate.
# Serialize: the loser waits ~30ms on the lock, then hits the winner's fresh cache.
exec 9>"$rt/tmux_net_lock"
flock 9
fresh_emit
now=$(date +%s.%N)
read -r rx tx < <(awk 'NR>2{gsub(/:/," "); if($1!="lo"){r+=$2; t+=$10}} END{print r+0, t+0}' /proc/net/dev)
drx=0
dtx=0
dt=1
if [ -r "$ctr" ]; then
read -r prx ptx pnow <"$ctr"
dt=$(awk -v a="$now" -v b="$pnow" 'BEGIN{d=a-b; print (d>0.05)?d:1}')
drx=$((rx - prx))
dtx=$((tx - ptx))
[ "$drx" -lt 0 ] && drx=0
[ "$dtx" -lt 0 ] && dtx=0
fi
printf '%s %s %s\n' "$rx" "$tx" "$now" >"$ctr"
human() { awk -v b="$1" 'BEGIN{ split("B K M G T", u, " "); i=1;
while (b >= 1024 && i < 5) { b /= 1024; i++ }
if (i == 1) printf "%d%s", b, u[i]; else printf "%.1f%s", b, u[i] }'; }
dl=$(human "$(awk -v x="$drx" -v d="$dt" 'BEGIN{printf "%d", x/d}')")
ul=$(human "$(awk -v x="$dtx" -v d="$dt" 'BEGIN{printf "%d", x/d}')")
printf '%s %s %s\n' "$dl" "$ul" "$(date +%s)" >"$cache"
if [ "$which" = ul ]; then emit '\U0000f093' "$ul"; else emit '\U0000f019' "$dl"; fi
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env bash
# CPU temperature (°C) for the tmux status bar (thermometer glyph U+F050F).
# Sensor hunt, most→least specific: a CPU-ish thermal zone (x86_pkg_temp/…),
# else a CPU hwmon chip (coretemp/k10temp/…), else the hottest remaining zone
# (acpitz can be a bogus constant on desktops, hence last). Prints NOTHING when
# no sensor exists (VMs/containers) so the segment simply disappears — which is
# also why the trailing spacing lives in here, not in status-right.
cpu_zone=""
any_zone=""
hw=""
for d in /sys/class/thermal/thermal_zone*/; do
[ -r "${d}temp" ] && [ -r "${d}type" ] || continue
t=$(<"${d}temp")
case $t in '' | *[!0-9]*) continue ;; esac
ty=$(<"${d}type")
ty=${ty,,}
case $ty in *x86_pkg_temp* | *coretemp* | *k10temp* | *tctl* | *cpu*)
if [ -z "$cpu_zone" ] || [ "$t" -gt "$cpu_zone" ]; then cpu_zone=$t; fi
;;
esac
if [ -z "$any_zone" ] || [ "$t" -gt "$any_zone" ]; then any_zone=$t; fi
done
if [ -z "$cpu_zone" ]; then
for d in /sys/class/hwmon/hwmon*/; do
[ -r "${d}name" ] || continue
case $(<"${d}name") in coretemp | k10temp | zenpower | cpu_thermal)
for f in "${d}"temp*_input; do
[ -r "$f" ] || continue
t=$(<"$f")
case $t in '' | *[!0-9]*) continue ;; esac
if [ -z "$hw" ] || [ "$t" -gt "$hw" ]; then hw=$t; fi
done
;;
esac
done
fi
t=${cpu_zone:-${hw:-$any_zone}}
[ -n "$t" ] || exit 0
printf '\U000f050f %d° ' $(((t + 500) / 1000))
+61 -16
View File
@@ -2,13 +2,21 @@
# Without this, tmux collapses them to a plain Enter. Requires tmux 3.5+ for csi-u.
set -g extended-keys on
set -g extended-keys-format csi-u
# termux reports xterm-256color: without RGB every #rrggbb below is approximated to the 256
# palette on the phone, and without extkeys tmux never asks the terminal for Shift/Ctrl+Enter.
# (kitty is xterm-kitty, whose terminfo already carries both.)
set -as terminal-features 'xterm-256color:RGB:extkeys'
# Let apps wrap escape sequences in DCS tmux passthrough (Claude Code / pi notifications,
# Claude's progress bar) so they reach the outer terminal instead of being swallowed.
set -g allow-passthrough on
# Mouse/touch reporting. Chiefly so the window list in the status bar is
# tappable — tmux binds MouseDown1Status to `switch-client -t =` out of the box,
# and status-format[1] below keeps the `range=window|…` markers that make each
# entry a hit target, so a tap on a tab switches to it with no extra binding.
# Wheel over the bar cycles windows; right-click a tab or the session name for
# tmux's built-in menus (no touch equivalent — a phone can't right-click).
# tmux's built-in menus; tmux-menus (plugin below) puts the same menus on prefix+\,
# which a phone can tap through.
#
# The cost: drag-select now goes to tmux instead of kitty, so hold Shift when you
# want kitty's own selection. Scroll wheel enters copy-mode on the pane rather
@@ -21,6 +29,18 @@ set -g base-index 1
setw -g pane-base-index 1
set -g renumber-windows on
# One long agent turn scrolls past tmux's default 2000 lines; resurrect captures pane
# contents from this buffer too. Applies to panes created after the change.
set -g history-limit 50000
# Copy-mode: vi keys, v/y like an editor. set-clipboard on pushes every copy — tmux's own and
# those apps inside make (nvim, pi) — over OSC 52 to the CLIENT terminal, so a yank on the
# phone lands on the phone and on kitty lands on the desktop, wherever the server runs.
setw -g mode-keys vi
bind -T copy-mode-vi v send -X begin-selection
bind -T copy-mode-vi y send -X copy-selection-and-cancel
set -s set-clipboard on
# --- Session persistence (survives reboot) ---------------------------------
# resurrect: save/restore sessions, windows, panes, layout and working dirs
# (prefix + Ctrl-s to save, prefix + Ctrl-r to restore).
@@ -29,6 +49,8 @@ set -g renumber-windows on
set -g @plugin 'tmux-plugins/tpm'
set -g @plugin 'tmux-plugins/tmux-resurrect'
set -g @plugin 'tmux-plugins/tmux-continuum'
# menus: tmux's context menus on prefix+\ (tappable; no right-click needed on a phone).
set -g @plugin 'jaclu/tmux-menus'
# Also capture pane scrollback and restore running programs (nvim, …).
# nvim 'session' strategy restores via Session.vim when present, else plain nvim.
@@ -87,20 +109,23 @@ set -g status-left "#{?#{e|>=:#{client_width},#{E:@status_cutoff}},#{E:@left_ful
# Middle: flat window list — inactive muted, active mauve/bold, 2-space gaps.
set -g window-status-separator " "
setw -g window-status-format "#[fg=#7f849c]#I #[fg=#6c7086]#W"
setw -g window-status-format "#[fg=#7f849c]#I#{P:#{E:@agent_badge}} #[fg=#6c7086]#W#[default]"
setw -g window-status-current-format "#[fg=#cba6f7,bold]#I #[fg=#cdd6f4,bold]#W"
setw -g window-status-activity-style "fg=#f9e2af"
# The agent-state bell flags the window (monitor-bell) so the outer terminal buzzes; tmux's
# default bell style is `reverse`, which painted the window entry — only the glyph gets colour.
setw -g window-status-bell-style default
# Right: net dl/ul · cpu · mem │ date │ clock. continuum prepends its invisible
# save hook to status-right (this block sits above the tpm `run` line, so the
# hook survives every reload). Metrics are scripts/*.sh, symlinked into
# ~/.config/tmux/scripts/. The hook lands ahead of the #{?} below, i.e. outside
# it, so it keeps running for every client regardless of which variant is drawn.
set -g @right_full "#[fg=#a6e3a1]#(~/.config/tmux/scripts/net.sh dl) #[fg=#f9e2af]#(~/.config/tmux/scripts/net.sh ul) #[fg=#89b4fa]#(~/.config/tmux/scripts/cpu.sh) #[fg=#94e2d5]#(~/.config/tmux/scripts/mem.sh) #[fg=#fab387]#(~/.config/tmux/scripts/temp.sh)#[fg=#45475a]│ #[fg=#a6adc8]%a %b %-d #[fg=#45475a]│ #[fg=#cba6f7,bold]%H:%M "
# Right: net dl/ul · cpu · mem · temp │ date │ clock. One #() job per refresh: metrics.sh
# prints the whole coloured segment (it was five separate scripts per tick). continuum
# prepends its invisible save hook to status-right (this block sits above the tpm `run`
# line, so the hook survives every reload). The hook lands ahead of the #{?} below, i.e.
# outside it, so it keeps running for every client regardless of which variant is drawn.
set -g @right_full "#(~/.config/tmux/scripts/metrics.sh full)#[fg=#45475a]│ #[fg=#a6adc8]%a %b %-d #[fg=#45475a]│ #[fg=#cba6f7,bold]%H:%M "
# Compact: cpu + mem only. Net rates and the temp glyph go first (widest, least
# useful on a phone), then the date, then the clock — a phone shows its own.
set -g @right_compact "#[fg=#89b4fa]#(~/.config/tmux/scripts/cpu.sh) #[fg=#94e2d5]#(~/.config/tmux/scripts/mem.sh) "
set -g @right_compact "#(~/.config/tmux/scripts/metrics.sh compact) "
set -g status-right "#{?#{e|>=:#{client_width},#{E:@status_cutoff}},#{E:@right_full},#{E:@right_compact}}"
@@ -125,28 +150,48 @@ bind S {
# Two rows: [0] drawn top-border rule · [1] the bar content. The rule doubles as
# the separator from the pane content above; tmux status height is whole rows, so
# one row above the content is the smallest gap that still shows a border.
# Full-width run of ─ (yes|head|paste avoids %, backslashes and nested parens so
# tmux's strftime/quote/#() parsing leave it alone); tmux truncates it to width.
# The rule is a literal run of ─ in @rule (300 columns; tmux clips it to the client
# width), so drawing it costs no process.
# status-format[1] is tmux 3.7's default content template (status-left/windows/right).
set -g status 2
set -g status-format[0] "#[fg=#6c7086,bg=default]#(yes ─ | head -n 400 | paste -sd '')"
set -g status-format[1] "#[align=left range=left #{E:status-left-style}]#[push-default]#{T;=/#{status-left-length}:status-left}#[pop-default]#[norange default]#[list=on align=#{status-justify}]#[list=left-marker]<#[list=right-marker]>#[list=on]#{W:#[range=window|#{window_index} #{E:window-status-style}#{?#{&&:#{window_last_flag},#{!=:#{E:window-status-last-style},default}}, #{E:window-status-last-style},}#{?#{&&:#{window_bell_flag},#{!=:#{E:window-status-bell-style},default}}, #{E:window-status-bell-style},#{?#{&&:#{||:#{window_activity_flag},#{window_silence_flag}},#{!=:#{E:window-status-activity-style},default}}, #{E:window-status-activity-style},}}]#[push-default]#{T:window-status-format}#[pop-default]#[norange default]#{?loop_last_flag,,#{E:window-status-separator}},#[range=window|#{window_index} list=focus #{?#{!=:#{E:window-status-current-style},default},#{E:window-status-current-style},#{E:window-status-style}}#{?#{&&:#{window_last_flag},#{!=:#{E:window-status-last-style},default}}, #{E:window-status-last-style},}#{?#{&&:#{window_bell_flag},#{!=:#{E:window-status-bell-style},default}}, #{E:window-status-bell-style},#{?#{&&:#{||:#{window_activity_flag},#{window_silence_flag}},#{!=:#{E:window-status-activity-style},default}}, #{E:window-status-activity-style},}}]#[push-default]#{T:window-status-current-format}#[pop-default]#[norange list=on default]#{?loop_last_flag,,#{E:window-status-separator}}}#[range=user|newwin] #[fg=#6c7086]+#[default] #[norange]#[nolist align=right range=right #{E:status-right-style}]#[push-default]#{T;=/#{status-right-length}:status-right}#[pop-default]#[norange default]"
set -g @rule "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────"
set -g status-format[0] "#[fg=#6c7086,bg=default]#{@rule}"
set -g status-format[1] "#[align=left range=left #{E:status-left-style}]#[push-default]#{T;=/#{status-left-length}:status-left}#[pop-default]#[norange default]#[list=on align=#{status-justify}]#[list=left-marker]<#[list=right-marker]>#[list=on]#{W:#[range=window|#{window_index} #{E:window-status-style}#{?#{&&:#{window_last_flag},#{!=:#{E:window-status-last-style},default}}, #{E:window-status-last-style},}#{?#{&&:#{window_bell_flag},#{!=:#{E:window-status-bell-style},default}}, #{E:window-status-bell-style},#{?#{&&:#{||:#{window_activity_flag},#{window_silence_flag}},#{!=:#{E:window-status-activity-style},default}}, #{E:window-status-activity-style},}}]#[push-default]#{T:window-status-format}#[pop-default]#[norange default]#{?loop_last_flag,,#{E:window-status-separator}},#[range=window|#{window_index} list=focus #{?#{!=:#{E:window-status-current-style},default},#{E:window-status-current-style},#{E:window-status-style}}#{?#{&&:#{window_last_flag},#{!=:#{E:window-status-last-style},default}}, #{E:window-status-last-style},}#{?#{&&:#{window_bell_flag},#{!=:#{E:window-status-bell-style},default}}, #{E:window-status-bell-style},#{?#{&&:#{||:#{window_activity_flag},#{window_silence_flag}},#{!=:#{E:window-status-activity-style},default}}, #{E:window-status-activity-style},}}]#[push-default]#{T:window-status-current-format}#[pop-default]#[norange list=on default]#{?loop_last_flag,,#{E:window-status-separator}}}#[range=user|newwin] #[fg=#6c7086]+#[default] #[norange]#[range=user|agentnext] #[fg=#6c7086]»#[default] #[norange]#[nolist align=right range=right #{E:status-right-style}]#[push-default]#{T;=/#{status-right-length}:status-right}#[pop-default]#[norange default]"
# The `+` appended to the window list above sits in a `range=user|newwin`
# marker. Per tmux(1), a user range triggers the same `Status` mouse key as a
# window range and exposes its argument in #{mouse_status_range} — window ranges
# report the literal string "window" — so one binding can serve both: click a
# tab to select it (tmux's stock behaviour, kept in the else branch), click the
# `+` to open a new window. Padded with spaces on both sides so it's a wide
# enough target to hit with a thumb.
# `+` to open a new window, click the `»` to jump to the next window with an agent
# mark (scripts/agent-next.sh, also prefix+Tab). Padded with spaces on both sides so
# each is a wide enough target to hit with a thumb.
bind -n MouseDown1Status {
if -F '#{==:#{mouse_status_range},newwin}' {
new-window
} {
switch-client -t =
if -F '#{==:#{mouse_status_range},agentnext}' {
run-shell '~/.config/tmux/scripts/agent-next.sh'
} {
switch-client -t =
}
}
}
# Agent state per pane — set by ~/.config/tmux/scripts/agent-state.sh <tool> <state> from the
# Claude Code / codex / pi / opencode hooks: @agent = busy|done|input|error. @agent_badge draws
# one glyph glued to the window number ("2✓ name") in the window list, the same for every tool: ✓ done (green),
# ? waiting for input (yellow), ! error (red); busy draws nothing. Visiting the pane only takes
# the colour away (pane-focus-in sets @agent_seen; needs focus-events): the glyph stays, grey,
# until the next prompt turns the state busy. prefix K clears every mark; prefix Tab (or the »
# in the bar) jumps to the next window with a mark, coloured ones first. A coloured glyph
# always means "this happened while you were not looking".
set -g @agent_badge "#{?#{==:#{@agent},done},#{?#{@agent_seen},#[fg=#6c7086],#[fg=#a6e3a1]}✓,#{?#{==:#{@agent},input},#{?#{@agent_seen},#[fg=#6c7086],#[fg=#f9e2af]}?,#{?#{==:#{@agent},error},#{?#{@agent_seen},#[fg=#6c7086],#[fg=#f38ba8]}!,}}}"
set -g focus-events on
set-hook -g pane-focus-in 'set -p @agent_seen 1'
bind Tab run-shell '~/.config/tmux/scripts/agent-next.sh'
bind K run-shell 'for p in $(tmux list-panes -a -F "##{pane_id}"); do tmux set -p -t $p -u @agent; tmux set -p -t $p -u @agent_seen; done' \; display-message "agent marks cleared"
# Round out the theme so no default green leaks into borders/messages/modes.
set -g pane-border-style "fg=#313244"
set -g pane-active-border-style "fg=#cba6f7"