diff --git a/configuration.nix b/configuration.nix index e4974ee..1db61f6 100644 --- a/configuration.nix +++ b/configuration.nix @@ -34,6 +34,29 @@ services.resolved.enable = true; + services.searx = { + enable = true; + openFirewall = false; + settings = { + use_default_settings.engines.keep_only = [ "google" ]; + general.instance_name = "Local Google"; + search = { + autocomplete = ""; + formats = [ "html" ]; + }; + server = { + bind_address = "127.0.0.1"; + port = 8888; + base_url = "http://127.0.0.1:8888/"; + limiter = false; + public_instance = false; + image_proxy = true; + method = "GET"; + secret_key = "local-only-google-searxng"; + }; + }; + }; + networking.firewall.enable = true; # Required for Tailscale networking.firewall.checkReversePath = "loose"; @@ -95,7 +118,7 @@ Restart = "always"; RestartSec = 5; TimeoutStartSec = 75; - ExecStart = "/etc/profiles/per-user/jet/bin/opencode serve --hostname 127.0.0.1 --port 4096"; + ExecStart = "/etc/profiles/per-user/jet/bin/o serve --hostname 127.0.0.1 --port 4096"; WorkingDirectory = config.users.users.jet.home; }; }; diff --git a/flake.lock b/flake.lock index 74626eb..83afb70 100644 --- a/flake.lock +++ b/flake.lock @@ -362,11 +362,11 @@ "nixpkgs": "nixpkgs_4" }, "locked": { - "lastModified": 1779821945, - "narHash": "sha256-6NHOS9mQiUMEDqgnuQXhqgckQ9ZR03PPW3b6P7XdUYQ=", + "lastModified": 1780182789, + "narHash": "sha256-PSRa+XhcJI/y+j7iSxGQZIQMPKYAq8od36RO8CcXL+c=", "owner": "anomalyco", "repo": "opencode", - "rev": "fdfd0afed7fddbe852ea53b5a75ce1ea8ad725a2", + "rev": "30f9780561e703147745945490a46646ca27670b", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index d0330c8..6e9eb43 100644 --- a/flake.nix +++ b/flake.nix @@ -7,9 +7,7 @@ home-manager.inputs.nixpkgs.follows = "nixpkgs"; ghostty.url = "github:ghostty-org/ghostty/main"; helix.url = "github:helix-editor/helix/master"; - opencode = { - url = "github:anomalyco/opencode/dev"; - }; + opencode.url = "github:anomalyco/opencode/dev"; t3code.url = "github:jetpham/nix-t3code"; nixos-hardware.url = "github:NixOS/nixos-hardware"; zen-browser = { @@ -63,8 +61,16 @@ inputs.nur.overlays.default inputs.ghostty.overlays.default inputs.helix.overlays.default + opencode.overlays.default (final: prev: { - opencode = opencode.packages.${prev.stdenv.hostPlatform.system}.opencode; + # opencode's dev branch asks for Bun 1.3.14, but this revision builds and runs with nixpkgs' Bun 1.3.13. + opencode = prev.opencode.overrideAttrs (old: { + postPatch = (old.postPatch or "") + '' + substituteInPlace package.json \ + --replace-fail "bun@1.3.14" "bun@1.3.13" + ''; + }); + opencode-original = final.opencode; }) ]; } diff --git a/gnome-extensions/opencode-token-usage/extension.js b/gnome-extensions/opencode-token-usage/extension.js new file mode 100644 index 0000000..69ba840 --- /dev/null +++ b/gnome-extensions/opencode-token-usage/extension.js @@ -0,0 +1,207 @@ +import Clutter from 'gi://Clutter'; +import Cogl from 'gi://Cogl'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; + +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; + +const COMMAND = '@opencodeTokenUsage@/bin/opencode-token-usage'; +const REFRESH_SECONDS = 60; +const GRAPH_WIDTH = 96; +const GRAPH_HEIGHT = 16; +const CLASSES = [ + 'opencode-token-usage-normal', + 'opencode-token-usage-warning', + 'opencode-token-usage-critical', + 'opencode-token-usage-missing', +]; + +function colorFromString(colorString) { + if (Cogl.Color.from_string) { + const [ok, color] = Cogl.Color.from_string(colorString); + if (ok) + return color; + } + + return Clutter.Color.from_string(colorString)[1]; +} + +function setSourceColor(cr, color) { + if (Clutter.cairo_set_source_color) + Clutter.cairo_set_source_color(cr, color); + else + cr.setSourceColor(color); +} + +const TokenUsageGraph = GObject.registerClass( +class TokenUsageGraph extends St.DrawingArea { + constructor() { + super({ + style_class: 'opencode-token-usage-graph', + reactive: false, + }); + + this._values = []; + this._background = colorFromString('#ffffff16'); + this._fill = colorFromString('#23863699'); + this._line = colorFromString('#58a6ff'); + this._scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + this.set_width(GRAPH_WIDTH * this._scaleFactor); + this.set_height(GRAPH_HEIGHT * this._scaleFactor); + this.connect('repaint', this._draw.bind(this)); + } + + setValues(values) { + this._values = Array.isArray(values) ? values.filter(value => Number.isFinite(value)) : []; + this.queue_repaint(); + } + + _point(index, width, height, max) { + const x = this._values.length <= 1 ? width : index * (width / (this._values.length - 1)); + const y = height - 1 - (this._values[index] / max) * Math.max(1, height - 2); + return [x, y]; + } + + _draw() { + const [width, height] = this.get_surface_size(); + const cr = this.get_context(); + + setSourceColor(cr, this._background); + cr.rectangle(0, 0, width, height); + cr.fill(); + + if (this._values.length === 0) { + cr.$dispose(); + return; + } + + const max = Math.max(1, ...this._values); + + cr.moveTo(0, height); + for (let index = 0; index < this._values.length; index++) { + const [x, y] = this._point(index, width, height, max); + cr.lineTo(x, y); + } + cr.lineTo(width, height); + cr.closePath(); + setSourceColor(cr, this._fill); + cr.fill(); + + const [x0, y0] = this._point(0, width, height, max); + cr.moveTo(x0, y0); + for (let index = 1; index < this._values.length; index++) { + const [x, y] = this._point(index, width, height, max); + cr.lineTo(x, y); + } + cr.setLineWidth(Math.max(1, this._scaleFactor)); + setSourceColor(cr, this._line); + cr.stroke(); + cr.$dispose(); + } +}); + +const TokenUsageIndicator = GObject.registerClass( +class TokenUsageIndicator extends PanelMenu.Button { + constructor() { + super(0.0, 'OpenCode Token Usage'); + + this.add_style_class_name('opencode-token-usage'); + this._prefix = new St.Label({ + text: 'tok', + y_align: Clutter.ActorAlign.CENTER, + style_class: 'opencode-token-usage-label', + }); + this._graph = new TokenUsageGraph(); + this._value = new St.Label({ + text: '0', + y_align: Clutter.ActorAlign.CENTER, + style_class: 'opencode-token-usage-label', + }); + this.add_child(this._prefix); + this.add_child(this._graph); + this.add_child(this._value); + + this._refresh(); + this._timer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, REFRESH_SECONDS, () => { + this._refresh(); + return GLib.SOURCE_CONTINUE; + }); + } + + _setClass(nextClass) { + for (const item of CLASSES) + this.remove_style_class_name(item); + + this.add_style_class_name(`opencode-token-usage-${nextClass || 'normal'}`); + } + + _applyPayload(payload) { + this._value.text = payload.value || '0'; + this._graph.setValues(payload.values || []); + this.menu.removeAll(); + for (const line of (payload.tooltip || 'OpenCode token usage').split('\n')) { + const item = new PopupMenu.PopupMenuItem(line, {reactive: false, can_focus: false}); + this.menu.addMenuItem(item); + } + this._setClass(payload.class || 'normal'); + } + + _refresh() { + let proc; + try { + proc = Gio.Subprocess.new( + [COMMAND], + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE + ); + } catch (error) { + this._applyPayload({ + text: 'tok error', + tooltip: `Unable to start token usage command: ${error.message}`, + class: 'critical', + }); + return; + } + + proc.communicate_utf8_async(null, null, (subprocess, result) => { + try { + const [, stdout, stderr] = subprocess.communicate_utf8_finish(result); + if (!subprocess.get_successful()) + throw new Error(stderr.trim() || `command exited ${subprocess.get_exit_status()}`); + + this._applyPayload(JSON.parse(stdout)); + } catch (error) { + this._applyPayload({ + text: 'tok error', + tooltip: `Unable to read OpenCode token usage: ${error.message}`, + class: 'critical', + }); + } + }); + } + + destroy() { + if (this._timer) { + GLib.Source.remove(this._timer); + this._timer = null; + } + + super.destroy(); + } +}); + +export default class OpenCodeTokenUsageExtension extends Extension { + enable() { + this._indicator = new TokenUsageIndicator(); + Main.panel.addToStatusArea('opencode-token-usage', this._indicator, 0, 'right'); + } + + disable() { + this._indicator?.destroy(); + this._indicator = null; + } +} diff --git a/gnome-extensions/opencode-token-usage/metadata.json b/gnome-extensions/opencode-token-usage/metadata.json new file mode 100644 index 0000000..baa7c30 --- /dev/null +++ b/gnome-extensions/opencode-token-usage/metadata.json @@ -0,0 +1,6 @@ +{ + "uuid": "opencode-token-usage@jetpham.github.com", + "name": "OpenCode Token Usage", + "description": "Shows OpenCode token usage and daily cost estimates in the GNOME top bar.", + "shell-version": ["49"] +} diff --git a/gnome-extensions/opencode-token-usage/stylesheet.css b/gnome-extensions/opencode-token-usage/stylesheet.css new file mode 100644 index 0000000..01d9ef1 --- /dev/null +++ b/gnome-extensions/opencode-token-usage/stylesheet.css @@ -0,0 +1,22 @@ +.opencode-token-usage { + spacing: 5px; +} + +.opencode-token-usage-label { + font-family: "CommitMono Nerd Font", monospace; + font-feature-settings: "tnum"; +} + +.opencode-token-usage-graph { + min-width: 96px; + min-height: 16px; +} + +.opencode-token-usage-warning .opencode-token-usage-label { + color: #d29922; +} + +.opencode-token-usage-critical .opencode-token-usage-label, +.opencode-token-usage-missing .opencode-token-usage-label { + color: #f85149; +} diff --git a/home-modules/browser.nix b/home-modules/browser.nix index d462ab3..15415d4 100644 --- a/home-modules/browser.nix +++ b/home-modules/browser.nix @@ -100,6 +100,8 @@ in isDefault = true; settings = { "identity.fxaccounts.enabled" = false; + "browser.search.suggest.enabled" = false; + "browser.urlbar.suggest.searches" = false; "font.default.ja" = "sans-serif"; "font.default.ko" = "sans-serif"; "font.default.x-unicode" = "sans-serif"; @@ -172,10 +174,18 @@ in ''; extensions.packages = zenQolExtensions; search = { - default = "SearXNG"; - privateDefault = "SearXNG"; + default = "Local Google"; + privateDefault = "Local Google"; force = true; engines = { + "Local Google" = { + urls = [ { template = "http://127.0.0.1:8888/search?q={searchTerms}"; } ]; + definedAliases = [ "@lg" ]; + }; + "Google Web" = { + urls = [ { template = "https://www.google.com/search?q={searchTerms}&pws=0&udm=14"; } ]; + definedAliases = [ "@g" ]; + }; "SearXNG" = { urls = [ { template = "https://search.extremist.software/search?q={searchTerms}"; } ]; definedAliases = [ "@s" ]; diff --git a/home-modules/desktop.nix b/home-modules/desktop.nix index f656e82..83fa51b 100644 --- a/home-modules/desktop.nix +++ b/home-modules/desktop.nix @@ -180,6 +180,7 @@ in "auto-move-windows@gnome-shell-extensions.gcampax.github.com" "gnome-shell-extension-maximized-by-default@stiggimy.github.com" "no-titlebar-when-maximized@alec.ninja" + "opencode-token-usage@jetpham.github.com" "evil-bit-toggle@jetpham.github.com" "reduced-motion-toggle@jetpham.github.com" ]; diff --git a/home-modules/lib.nix b/home-modules/lib.nix index 9baf298..78d7867 100644 --- a/home-modules/lib.nix +++ b/home-modules/lib.nix @@ -10,14 +10,165 @@ let name = "Jet"; email = "jet@extremist.software"; sshSigningKey = "~/.ssh/id_ed25519"; - wrappedOpencode = pkgs.symlinkJoin { - name = "opencode-wrapped"; - paths = [ pkgs.opencode ]; - nativeBuildInputs = [ pkgs.makeWrapper ]; - postBuild = '' - wrapProgram "$out/bin/opencode" \ - --set OPENCODE_DB opencode.db \ - --prefix LD_LIBRARY_PATH : "${pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ]}" + opencodeLibraryPath = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ]; + opencodeMine = pkgs.writeShellApplication { + name = "o"; + runtimeInputs = [ pkgs.curl ]; + text = '' + export OPENCODE_DB=opencode.db + export LD_LIBRARY_PATH="${opencodeLibraryPath}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + + if [ "$#" -eq 0 ] && curl \ + --fail \ + --silent \ + --connect-timeout 0.2 \ + --max-time 0.5 \ + --output /dev/null \ + http://127.0.0.1:4096/global/health; then + exec ${pkgs.opencode}/bin/opencode attach http://127.0.0.1:4096 --dir "$PWD" + fi + + exec ${pkgs.opencode}/bin/opencode "$@" + ''; + }; + opencodeDefault = pkgs.writeShellApplication { + name = "opencode"; + text = '' + export OPENCODE_DB=opencode.db + export LD_LIBRARY_PATH="${opencodeLibraryPath}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + exec ${pkgs.opencode}/bin/opencode "$@" + ''; + }; + opencodeOriginal = pkgs.writeShellApplication { + name = "oo"; + text = '' + export OPENCODE_DB=opencode.db + export LD_LIBRARY_PATH="${opencodeLibraryPath}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + exec ${pkgs.opencode-original}/bin/opencode "$@" + ''; + }; + opencodeTokenUsage = pkgs.writeShellApplication { + name = "opencode-token-usage"; + runtimeInputs = [ + pkgs.coreutils + pkgs.gawk + pkgs.jq + pkgs.sqlite + ]; + text = '' + set -euo pipefail + + data_home="''${XDG_DATA_HOME:-''${HOME}/.local/share}" + db_specs="''${OPENCODE_DBS:-opencode.db}" + plan_usd="''${CHATGPT_PLAN_USD:-200}" + + read -r -a db_spec_array <<< "$db_specs" + dbs=() + missing_dbs=() + db_summary="" + missing_summary="" + for db_spec in "''${db_spec_array[@]}"; do + case "$db_spec" in + /*) db="$db_spec" ;; + *) db="$data_home/opencode/$db_spec" ;; + esac + + if [ -r "$db" ]; then + dbs+=("$db") + db_name="''${db##*/}" + if [ -n "$db_summary" ]; then + db_summary="$db_summary, $db_name" + else + db_summary="$db_name" + fi + else + missing_dbs+=("$db") + db_name="''${db##*/}" + if [ -n "$missing_summary" ]; then + missing_summary="$missing_summary, $db_name" + else + missing_summary="$db_name" + fi + fi + done + + if [ "''${#dbs[@]}" -eq 0 ]; then + jq -cn --arg text "tok 0" --arg tooltip "OpenCode token DBs not found: $missing_summary" '{ text: $text, value: "0", values: [], tooltip: $tooltip, class: "missing" }' + exit 0 + fi + + now_ms=$(( $(date +%s) * 1000 )) + start_ms=$(( $(date -d 'today 00:00' +%s) * 1000 )) + + sessions=0 + input=0 + output=0 + reasoning=0 + cache_read=0 + cache_write=0 + for db in "''${dbs[@]}"; do + row=$(sqlite3 -separator '|' "$db" " + SELECT + COUNT(*), + COALESCE(SUM(tokens_input), 0), + COALESCE(SUM(tokens_output), 0), + COALESCE(SUM(tokens_reasoning), 0), + COALESCE(SUM(tokens_cache_read), 0), + COALESCE(SUM(tokens_cache_write), 0) + FROM session + WHERE time_created >= $start_ms; + ") + IFS='|' read -r db_sessions db_input db_output db_reasoning db_cache_read db_cache_write <<< "$row" + sessions=$(( sessions + db_sessions )) + input=$(( input + db_input )) + output=$(( output + db_output )) + reasoning=$(( reasoning + db_reasoning )) + cache_read=$(( cache_read + db_cache_read )) + cache_write=$(( cache_write + db_cache_write )) + done + + billable=$(( input + output + reasoning )) + with_cache=$(( billable + cache_read + cache_write )) + cost=$(awk -v input="$input" -v output="$output" -v reasoning="$reasoning" -v cache_read="$cache_read" -v cache_write="$cache_write" 'BEGIN { printf "%.2f", (input * 0.25 + (output + reasoning) * 2.00 + cache_read * 0.025 + cache_write * 0.25) / 1000000 }') + plan_pct=$(awk -v cost="$cost" -v plan="$plan_usd" 'BEGIN { if (plan > 0) printf "%.1f", cost / plan * 100; else printf "0.0" }') + + short_tokens() { + awk -v n="$1" 'BEGIN { if (n >= 1000000000) printf "%.1fB", n / 1000000000; else if (n >= 1000000) printf "%.1fM", n / 1000000; else if (n >= 1000) printf "%.1fk", n / 1000; else printf "%d", n }' + } + + graph=() + for ((i = 0; i < 24; i++)); do + graph[i]=0 + done + for db in "''${dbs[@]}"; do + values=$(sqlite3 -separator '|' -noheader "$db" " + WITH RECURSIVE buckets(start_ms, stop_ms, i) AS ( + SELECT (($now_ms / 3600000) - 23) * 3600000, (($now_ms / 3600000) - 22) * 3600000, 0 + UNION ALL + SELECT start_ms + 3600000, stop_ms + 3600000, i + 1 FROM buckets WHERE i < 23 + ) + SELECT buckets.i, COALESCE(SUM(COALESCE(tokens_input, 0) + COALESCE(tokens_output, 0) + COALESCE(tokens_reasoning, 0)), 0) + FROM buckets + LEFT JOIN session ON session.time_created >= buckets.start_ms AND session.time_created < buckets.stop_ms + GROUP BY buckets.i + ORDER BY buckets.i; + ") + while IFS='|' read -r index value; do + if [ -n "$index" ]; then + graph[index]=$(( graph[index] + value )) + fi + done <<< "$values" + done + graph_values=$(printf '%s\n' "''${graph[@]}" | jq -Rcs 'split("\n") | map(select(length > 0) | tonumber)') + billable_short=$(short_tokens "$billable") + text="tok $billable_short" + tooltip=$(printf 'OpenCode tokens today\nSources: %s\nGraph: last 24 hourly billable-token buckets\nSessions: %s\nBillable excl. cache: %s\nIncluding cache reads: %s\nInput: %s\nOutput: %s\nReasoning: %s\nCache read: %s\nEstimated GPT-5.5 Fast cost: $%s (%s%% of $%s)\nChatGPT plan limits: not exposed locally; this is an API-equivalent estimate.' "$db_summary" "$sessions" "$billable" "$with_cache" "$input" "$output" "$reasoning" "$cache_read" "$cost" "$plan_pct" "$plan_usd") + if [ "''${#missing_dbs[@]}" -gt 0 ]; then + tooltip=$(printf '%s\nMissing sources: %s' "$tooltip" "$missing_summary") + fi + class=$(awk -v pct="$plan_pct" 'BEGIN { if (pct >= 80) print "critical"; else if (pct >= 50) print "warning"; else print "normal" }') + + jq -cn --arg text "$text" --arg value "$billable_short" --arg tooltip "$tooltip" --arg class "$class" --argjson values "$graph_values" '{ text: $text, value: $value, values: $values, tooltip: $tooltip, class: $class }' ''; }; greptileSkills = pkgs.fetchFromGitHub { @@ -491,10 +642,13 @@ in inthAgentSkills name nasaApodWallpaper + opencodeDefault + opencodeMine + opencodeOriginal + opencodeTokenUsage signalStartup sshPublicKeys sshSigningKey - wrappedOpencode zenStartup zellijNewTabZoxide zellijPersistentSession diff --git a/home-modules/opencode.nix b/home-modules/opencode.nix index 93dfd44..7d592af 100644 --- a/home-modules/opencode.nix +++ b/home-modules/opencode.nix @@ -57,7 +57,7 @@ "--no-performance-crux" ]; enabled = true; - env = { + environment = { CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS = "1"; NO_UPDATE_NOTIFIER = "1"; NPM_CONFIG_AUDIT = "false"; @@ -84,6 +84,7 @@ - If there is no `flake.nix` and the tool is only needed temporarily, prefer `nix shell nixpkgs# -c `. - For persistent tools, prefer declarative Nix configuration. - Prefer `direnv` or `nix develop` before deciding a tool is missing. + - Do not put temporary code work, clones, generated project files, or Git worktrees under `/tmp`; use `~/Documents/tmp` instead so work is less likely to be cleared. - Never run `nixos-rebuild`, `nh os switch`, `nhs`, or other system switch commands unless explicitly asked. ''; diff --git a/home-modules/packages.nix b/home-modules/packages.nix index 6448364..ad0ea5d 100644 --- a/home-modules/packages.nix +++ b/home-modules/packages.nix @@ -107,6 +107,24 @@ let runHook postInstall ''; }; + + opencodeTokenUsageExtension = pkgs.stdenvNoCC.mkDerivation { + pname = "gnome-shell-extension-opencode-token-usage"; + version = "1"; + src = ../gnome-extensions/opencode-token-usage; + + installPhase = '' + runHook preInstall + + substituteInPlace extension.js \ + --replace-fail @opencodeTokenUsage@ ${homeLib.opencodeTokenUsage} + + mkdir -p "$out/share/gnome-shell/extensions/opencode-token-usage@jetpham.github.com" + cp -r . "$out/share/gnome-shell/extensions/opencode-token-usage@jetpham.github.com" + + runHook postInstall + ''; + }; in { @@ -116,7 +134,9 @@ in claude-code codex ffmpeg-full - homeLib.wrappedOpencode + homeLib.opencodeDefault + homeLib.opencodeMine + homeLib.opencodeOriginal inputs.t3code.packages.${pkgs.stdenv.hostPlatform.system}.t3code-nightly skills homeLib.zellijNewTabZoxide @@ -190,6 +210,7 @@ in tailscaleQsGnome49 gnomeExtensions.wifi-qrcode evilBitToggleExtension + opencodeTokenUsageExtension reducedMotionToggleExtension nerd-fonts.commit-mono diff --git a/home-modules/shell.nix b/home-modules/shell.nix index 934df33..758b666 100644 --- a/home-modules/shell.nix +++ b/home-modules/shell.nix @@ -79,7 +79,6 @@ "dr" = "direnv reload"; "da" = "direnv allow"; "nfu" = "nix flake update"; - "o" = "opencode"; ".." = "z .."; j = "jj"; jgf = "jj git fetch";