Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
836 changes: 836 additions & 0 deletions spec/smith/cmux_notify_spec.cr

Large diffs are not rendered by default.

99 changes: 99 additions & 0 deletions spec/smith/config_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -839,3 +839,102 @@ describe "session settings" do
end
end
end

# Only the config file tier: merging the `CMUX_*` environment into this is
# `Smith::CmuxClient.resolve`, covered in cmux_notify_spec.cr. Keeping the two
# apart here is what makes it possible to say which tier a value came from.
describe "notify settings" do
it "is off and empty until a [notify] section says otherwise" do
with_sandbox do |temp_dir, _home|
settings = Smith::Config.load(make_project(temp_dir)).notify

# nil, not false: nobody asked either way, and that is the answer
# `CmuxClient.resolve` lets the terminal overrule.
settings.enabled.should be_nil
settings.socket_path.should be_nil
settings.surface_id.should be_nil
settings.workspace_id.should be_nil
settings.timeout.should eq(1.0)
end
end

it "reads the section" do
with_sandbox do |temp_dir, _home|
project = make_project(temp_dir, <<-TOML)
[notify]
enabled = true
socket_path = "/tmp/cmux.sock"
surface_id = "surface:1"
workspace_id = "workspace:1"
timeout = 2.5
TOML

settings = Smith::Config.load(project).notify

settings.enabled.should be_true
settings.socket_path.should eq("/tmp/cmux.sock")
settings.surface_id.should eq("surface:1")
settings.workspace_id.should eq("workspace:1")
settings.timeout.should eq(2.5)
settings.socket?.should be_true
settings.deliverable?.should be_true
end
end

it "lets the project config override the global one, key by key" do
with_sandbox do |temp_dir, home_dir|
File.write(File.join(home_dir, "config.toml"), <<-TOML)
[notify]
enabled = true
socket_path = "/global/cmux.sock"
surface_id = "global-surface"
TOML

project = make_project(temp_dir, <<-TOML)
[notify]
surface_id = "project-surface"
TOML

settings = Smith::Config.load(project).notify

settings.enabled.should be_true
settings.socket_path.should eq("/global/cmux.sock")
settings.surface_id.should eq("project-surface")
end
end

it "turns a blank setting into unset rather than into an empty value" do
# An empty `socket_path` is a key someone left behind, not a location. Read
# as a location it would shadow the environment's with nothing.
with_sandbox do |temp_dir, _home|
project = make_project(temp_dir, <<-TOML)
[notify]
socket_path = " "
surface_id = ""
TOML

settings = Smith::Config.load(project).notify

settings.socket_path.should be_nil
settings.surface_id.should be_nil
settings.socket?.should be_false
end
end

it "ignores values of the wrong type instead of refusing to start" do
with_sandbox do |temp_dir, _home|
project = make_project(temp_dir, <<-TOML)
[notify]
enabled = "yes"
socket_path = 42
timeout = "soon"
TOML

settings = Smith::Config.load(project).notify

settings.enabled.should be_nil
settings.socket_path.should be_nil
settings.timeout.should eq(1.0)
end
end
end
32 changes: 32 additions & 0 deletions src/smith/cli.cr
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ require "./update"
require "./doctor"
require "./marketplace"
require "./ui"
require "./turn_notifier"

module Smith
class CLI
Expand Down Expand Up @@ -73,6 +74,7 @@ module Smith
# keep the plain renderer even on a TTY.
@interactive_tui : Bool = false
@tui_app : UI::App? = nil
@notify : Notify? = nil
@tui_warned : Bool = false
@update_check : Bool = false
@allow_unverified : Bool = false
Expand Down Expand Up @@ -684,6 +686,26 @@ module Smith
end
end

# Where completion notifications go. Built once, and a no-op anywhere cmux
# is not the terminal in use — so no call site has to ask first. Which
# config and which environment decide that is `Notify`'s business, and the
# reason this names one class rather than two.
private def notify : Notify
@notify ||= Notify.build(@config.notify)
end

# Which session this is, in one glance from another tab. The project
# directory is the part that tells two smith runs apart, since they are
# usually the same project run twice rather than two projects; a session
# that was named says its name, because that is what the user would have
# called it.
private def notify_subtitle(session_data : Session::Data?) : String?
project = File.basename(session_data.try(&.cwd) || Dir.current)
name = session_data.try(&.name)

name.nil? || name.empty? ? project : "#{project} · #{name}"
end

# Takes the whole session rather than its pieces: passing messages and the
# calibration ratio separately is how one call site came to carry the
# transcript without what had been learned about measuring it.
Expand Down Expand Up @@ -817,6 +839,16 @@ module Smith
renderer.handle(event)
end

# A second listener rather than a branch inside the first: a
# notification is not a rendering choice, and every renderer — plain,
# JSON, fullscreen — is served by the same one. Fresh per agent, so the
# subtitle is the session this agent runs and the text collected for it
# cannot carry over into one that replaced it.
notifier = TurnNotifier.new(notify, subtitle: notify_subtitle(session_data))
agent.on_event do |event|
notifier.handle(event)
end

plan = plan_session
plan.on_plan = ->(text : String) do
renderer.handle(Events::PlanPresented.new(text))
Expand Down
171 changes: 171 additions & 0 deletions src/smith/cmux_client.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
require "./cmux_clientable"
require "./null_cmux_client"
require "./notify_config"

module Smith
# The environment half of a cmux notification setup. `NotifyConfig.from_table`
# reads what the config file said; this merges in the `CMUX_*` variables the
# terminal exports into every process it spawns, decides which of the two
# wins, and turns the result into something `Smith::Notify` can talk to.
#
# This is the only place in smith that knows those variable names, that a
# socket is involved, or that a protocol exists. `Smith::Notify` sees a
# resolved config and a `CmuxClientable`; neither knows there is an
# environment, and neither reaches for one.
module CmuxClient
# In priority order. `CMUX_SOCKET_PATH` is the one cmux documents and the
# one its environment actually carries.
#
# The other two are kept because they cost nothing to check and because a
# spelling smith refused to read is a notification that silently does not
# arrive: `CMUX_SOCKET` is exported alongside the documented name — empty,
# in the environment this was written against, which is exactly why a
# resolution has to read the first value that says something rather than
# the first variable that is set — and `CMUX` is the spelling #120 named
# and the one a wrapper script is most likely to set itself.
SOCKET_PATH_KEYS = {"CMUX_SOCKET_PATH", "CMUX_SOCKET", "CMUX"}

# Two pairs of names for the same two values. cmux's own CLI and docs speak
# of tabs and panels, and the environment this was written against exports
# `CMUX_WORKSPACE_ID` and `CMUX_SURFACE_ID` carrying the same two ids its
# `CMUX_TAB_ID` and `CMUX_PANEL_ID` do — both halves verified, not assumed.
#
# Reading both costs nothing, and which pair a build exports is not
# something smith can ask about. The workspace and surface names come
# first: they are the ones observed, and the tab and panel names are the
# ones a build might stop exporting.
WORKSPACE_ID_KEYS = {"CMUX_WORKSPACE_ID", "CMUX_TAB_ID"}
SURFACE_ID_KEYS = {"CMUX_SURFACE_ID", "CMUX_PANEL_ID"}

# Values an environment variable can hold that mean "not set" rather than
# "set to this": a shell that exports one of these is saying the same thing
# as one that never exported it, so the next tier down gets its say.
#
# Not a hypothetical. cmux exports `CMUX_SOCKET=` empty alongside a
# populated `CMUX_SOCKET_PATH` in the same environment, so a resolution
# that read the first set *variable* rather than the first set *value*
# would find no socket at all — and would then conclude the session is not
# running inside cmux, because the socket is what says so.
FALSEY = {"", "0", "false", "no", "off"}

# Config plus environment, environment winning. Inside cmux the variables
# describe the terminal that is running right now — this surface, this
# workspace, this socket — so they are the more accurate answer than
# anything a config file could have been written with.
#
# `env` is a parameter rather than `ENV` so the resolution is testable
# without touching the process environment.
def self.resolve(config : NotifyConfig, env : Hash(String, String?) = env_snapshot) : NotifyConfig
# A socket cmux itself put into the environment, as opposed to one a
# config file named. Kept apart because the two mean different things:
# the first says "this terminal is inside cmux, right now", the second
# only says "here is a location".
live_socket = socket_from_env(env)

NotifyConfig.new(
enabled: enabled(config, live_socket),
socket_path: live_socket || config.socket_path,
surface_id: first_of(env, SURFACE_ID_KEYS) || config.surface_id,
workspace_id: first_of(env, WORKSPACE_ID_KEYS) || config.workspace_id,
timeout: config.timeout
)
end

# Resolve, then build. The one call a caller that is not itself resolving
# anything needs.
def self.build(config : NotifyConfig, env : Hash(String, String?) = env_snapshot) : CmuxClientable
client(resolve(config, env))
end

# The client for an already-resolved config. Kept separate from `resolve`
# because resolving is pure: a diagnostic can compute the effective config
# without anything being connected.
#
# Null whenever delivering is impossible, so the caller never has to ask
# first.
def self.client(config : NotifyConfig) : CmuxClientable
return NullCmuxClient.new unless config.deliverable?

# TODO: open the unix socket at `config.socket_path` and speak to it
# (#120). Until then every config resolves to null — the plumbing is
# here, the wire is not, and nothing silently claims a notification was
# delivered.
NullCmuxClient.new
end

# Whether notifications go out. Three answers, because there are three
# questions and only two of them belong to the config file:
#
# An explicit `enabled = false` is honoured no matter what the terminal
# says — that is somebody turning them off, and being inside cmux is not a
# reason to overrule them. An explicit `true` is honoured as readily.
#
# Absent is nobody having said, and that is where the terminal gets its
# say: cmux announcing a socket *is* the announcement that this session is
# running inside it, which is the situation a completion notification
# exists for. So the default is on inside cmux and off everywhere else,
# and a run started in a plain terminal is unchanged by this feature.
#
# Deliberately not decided by whether a socket path resolved *from config*:
# pointing at a location is not the same statement as "you are inside me",
# and treating it as one would switch notifications on for a config file
# that only ever meant to say where the socket is.
private def self.enabled(config : NotifyConfig, live_socket : String?) : Bool
explicit = config.enabled
return explicit unless explicit.nil?

!live_socket.nil?
end

private def self.socket_from_env(env : Hash(String, String?)) : String?
SOCKET_PATH_KEYS.each do |key|
value = truthy(env, key)
next if value.nil?
# A bare `CMUX` holds a flag in the wild — `CMUX=1` — and reading that
# as a location would connect to a file called `1` in the current
# directory. Only something shaped like a path is taken as one.
next if key == "CMUX" && !value.includes?("/")
return value
end

nil
end

# The first of `keys` that holds a value saying something, so a documented
# spelling can stand in for an exported one without either being assumed.
private def self.first_of(env : Hash(String, String?), keys : Enumerable(String)) : String?
keys.each do |key|
value = presence(env, key)
return value unless value.nil?
end

nil
end

# A set variable that says something. Whitespace-only and the usual
# spellings of "off" come back as nil, which is what lets the tier below
# speak.
private def self.truthy(env : Hash(String, String?), key : String) : String?
value = NotifyConfig.normalize(env[key]?)
return nil if value.nil?
FALSEY.includes?(value.downcase) ? nil : value
end

private def self.presence(env : Hash(String, String?), key : String) : String?
NotifyConfig.normalize(env[key]?)
end

# A copy of the process environment, in the type the resolution works in.
#
# Not `ENV` itself as the default: `ENV` is not a `Hash`, and a default
# argument is only checked where the method is actually called — so
# `= ENV` sat unobjected until something called `resolve`, and it broke the
# build rather than failing quietly. Snapshotting also means a resolution
# cannot observe the environment changing underneath it mid-call.
private def self.env_snapshot : Hash(String, String?)
snapshot = Hash(String, String?).new
ENV.each { |key, value| snapshot[key] = value }
snapshot
end
end
end
25 changes: 25 additions & 0 deletions src/smith/cmux_clientable.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
require "json"

module Smith
# The seam `Smith::Notify` talks to: something that can hand a cmux
# notification payload to a cmux daemon.
#
# Deliberately the whole surface. It exposes no socket, no path and no
# protocol, so neither the caller nor the specs need to know which one the
# real implementation happens to speak — and the null one can keep pretending
# there is a daemon at all.
abstract class CmuxClientable
# Hand a ready-made notification payload to cmux.
#
# `payload` is what goes over the wire, keys and all — implementations only
# transport it. Returns true when cmux accepted it, false when it did not
# or when there was nothing to accept. A failure here is never an
# exception: notification delivery must not be able to take a run down.
abstract def notify(payload : Hash(String, JSON::Any)) : Bool

# True when there is somewhere to deliver to. Used for diagnostics, never
# as a precondition — `notify` on a client that answers false is a no-op,
# not an error.
abstract def available? : Bool
end
end
13 changes: 13 additions & 0 deletions src/smith/config.cr
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require "./mentions"
require "./sandbox"
require "./media"
require "./pricing"
require "./notify_config"

module Smith
# Resolved configuration, merged from (lowest to highest priority):
Expand Down Expand Up @@ -627,6 +628,18 @@ module Smith
)
end

# The `[notify]` section, and only that: what the config file says about
# cmux desktop notifications.
#
# The `CMUX_*` environment the terminal exports is the other half, and this
# deliberately does not reach for it — a config file and a terminal are two
# questions, and keeping them apart is what lets either be reasoned about.
# The record does the reading: it is pure data, so no name that knows about
# sockets or variables appears here at all.
def notify : NotifyConfig
NotifyConfig.from_table(lookup("notify").try(&.as_h?))
end

# Consumed by Subagents::Supervisor via CLI#build_agent. max_children = 0
# switches subagents off entirely — the agent tool is then not registered.
def subagents : SubagentSettings
Expand Down
Loading