claude-office 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0e027095a406422081e3db847e481224a96d6e2289983b66cec9076553c3d477
4
+ data.tar.gz: 98b602f6c16c80b79d146095500d2b38075685c6d7ed5e270a50bbcfea083ac0
5
+ SHA512:
6
+ metadata.gz: 2f43f8f32940f07f81c368eadcea57c788ec956177571d3e31609e92583b8e85f1a7d15dfb7978a8f6aba6868a4f4061f672a265dd3903777a143ebd41f08ef0
7
+ data.tar.gz: c5fa2675865370aebd98520465677c616c858698464a00bc01191aa099bc37e2b6f8cae38b21b168e349996727590ce3475ca15baee6c2ea369bb27b4a5f8abb
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fernando Ruiz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/bin/claude-office ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../lib/claude_office"
4
+ require_relative "../lib/claude_office/cli"
5
+
6
+ ClaudeOffice::CLI.run(ARGV)
@@ -0,0 +1,78 @@
1
+ require_relative "sub_agent"
2
+
3
+ module ClaudeOffice
4
+ module Agents
5
+ class Agent
6
+ TOOL_ANIMATIONS = {
7
+ "Read" => :reading, "Glob" => :reading, "Grep" => :reading,
8
+ "WebFetch" => :reading, "WebSearch" => :reading,
9
+ "Edit" => :typing, "Write" => :typing, "NotebookEdit" => :typing,
10
+ "Bash" => :running,
11
+ "Task" => :typing,
12
+ "AskUserQuestion" => :waiting,
13
+ "EnterPlanMode" => :reading,
14
+ }.freeze
15
+
16
+ attr_reader :session_id, :state, :active_tools, :status_text,
17
+ :animation, :desk_position, :sub_agents, :position
18
+
19
+ def initialize(session_id:, desk_position:)
20
+ @session_id = session_id
21
+ @desk_position = desk_position
22
+ @position = desk_position.dup
23
+ @state = :idle
24
+ @active_tools = {}
25
+ @status_text = ""
26
+ @animation = :idle
27
+ @sub_agents = {}
28
+ end
29
+
30
+ def tool_started(tool_id, tool_name, status_text)
31
+ @active_tools[tool_id] = tool_name
32
+ @status_text = status_text
33
+ @animation = TOOL_ANIMATIONS.fetch(tool_name, :idle)
34
+ @state = :working
35
+ end
36
+
37
+ def tool_done(tool_id)
38
+ tool_name = @active_tools.delete(tool_id)
39
+
40
+ if tool_name == "Task"
41
+ @sub_agents.delete(tool_id)
42
+ end
43
+
44
+ if @active_tools.empty?
45
+ @state = :idle
46
+ @animation = :idle
47
+ @status_text = ""
48
+ else
49
+ last_tool = @active_tools.values.last
50
+ @animation = TOOL_ANIMATIONS.fetch(last_tool, :idle)
51
+ end
52
+ end
53
+
54
+ def turn_ended
55
+ @state = :waiting
56
+ @animation = :waiting
57
+ @active_tools.clear
58
+ @sub_agents.clear
59
+ @status_text = ""
60
+ end
61
+
62
+ def new_turn
63
+ @state = :idle
64
+ @animation = :idle
65
+ end
66
+
67
+ def sub_agent_tool_started(parent_tool_id, tool_id, tool_name, status_text)
68
+ @sub_agents[parent_tool_id] ||= SubAgent.new(parent_tool_id: parent_tool_id)
69
+ @sub_agents[parent_tool_id].tool_started(tool_id, tool_name, status_text)
70
+ end
71
+
72
+ def sub_agent_tool_done(parent_tool_id, tool_id)
73
+ sub = @sub_agents[parent_tool_id]
74
+ sub&.tool_done(tool_id)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,35 @@
1
+ require_relative "agent"
2
+
3
+ module ClaudeOffice
4
+ module Agents
5
+ class Registry
6
+ attr_reader :agents
7
+
8
+ def initialize
9
+ @agents = {}
10
+ end
11
+
12
+ def add(session_id, desk_position)
13
+ agent = Agent.new(session_id: session_id, desk_position: desk_position)
14
+ @agents[session_id] = agent
15
+ agent
16
+ end
17
+
18
+ def get(session_id)
19
+ @agents[session_id]
20
+ end
21
+
22
+ def remove(session_id)
23
+ @agents.delete(session_id)
24
+ end
25
+
26
+ def count
27
+ @agents.size
28
+ end
29
+
30
+ def each(&block)
31
+ @agents.each_value(&block)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,35 @@
1
+ module ClaudeOffice
2
+ module Agents
3
+ class SubAgent
4
+ attr_reader :parent_tool_id, :active_tools, :status_text, :animation
5
+
6
+ TOOL_ANIMATIONS = {
7
+ "Read" => :reading, "Glob" => :reading, "Grep" => :reading,
8
+ "WebFetch" => :reading, "WebSearch" => :reading,
9
+ "Edit" => :typing, "Write" => :typing, "NotebookEdit" => :typing,
10
+ "Bash" => :running,
11
+ }.freeze
12
+
13
+ def initialize(parent_tool_id:)
14
+ @parent_tool_id = parent_tool_id
15
+ @active_tools = {}
16
+ @status_text = ""
17
+ @animation = :idle
18
+ end
19
+
20
+ def tool_started(tool_id, tool_name, status_text)
21
+ @active_tools[tool_id] = tool_name
22
+ @status_text = status_text
23
+ @animation = TOOL_ANIMATIONS.fetch(tool_name, :idle)
24
+ end
25
+
26
+ def tool_done(tool_id)
27
+ @active_tools.delete(tool_id)
28
+ if @active_tools.empty?
29
+ @animation = :idle
30
+ @status_text = ""
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,22 @@
1
+ module ClaudeOffice
2
+ module Animation
3
+ class FrameCycle
4
+ attr_reader :frame
5
+
6
+ def initialize(frame_count:, frames_per_tick: 10)
7
+ @frame_count = frame_count
8
+ @frames_per_tick = frames_per_tick
9
+ @tick = 0
10
+ @frame = 0
11
+ end
12
+
13
+ def advance
14
+ @tick += 1
15
+ if @tick >= @frames_per_tick
16
+ @tick = 0
17
+ @frame = (@frame + 1) % @frame_count
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,48 @@
1
+ require "harmonica"
2
+
3
+ module ClaudeOffice
4
+ module Animation
5
+ class SpringMover
6
+ def initialize(fps: 30)
7
+ @spring = Harmonica::Spring.new(
8
+ delta_time: Harmonica.fps(fps),
9
+ angular_frequency: 5.0,
10
+ damping_ratio: 0.8
11
+ )
12
+ @x = 0.0
13
+ @y = 0.0
14
+ @vx = 0.0
15
+ @vy = 0.0
16
+ @target_x = 0.0
17
+ @target_y = 0.0
18
+ end
19
+
20
+ def set_position(x, y)
21
+ @x = x.to_f
22
+ @y = y.to_f
23
+ @target_x = x.to_f
24
+ @target_y = y.to_f
25
+ @vx = 0.0
26
+ @vy = 0.0
27
+ end
28
+
29
+ def set_target(x, y)
30
+ @target_x = x.to_f
31
+ @target_y = y.to_f
32
+ end
33
+
34
+ def update
35
+ @x, @vx = @spring.update(@x, @vx, @target_x)
36
+ @y, @vy = @spring.update(@y, @vy, @target_y)
37
+ end
38
+
39
+ def position
40
+ [@x.round, @y.round]
41
+ end
42
+
43
+ def arrived?(threshold: 0.5)
44
+ (@x - @target_x).abs < threshold && (@y - @target_y).abs < threshold
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,149 @@
1
+ require "bubbletea"
2
+ require "lipgloss"
3
+ require_relative "transcript/watcher"
4
+ require_relative "agents/registry"
5
+ require_relative "office/grid"
6
+ require_relative "office/pathfinder"
7
+ require_relative "rendering/renderer"
8
+ require_relative "animation/frame_cycle"
9
+ require_relative "notification"
10
+
11
+ module ClaudeOffice
12
+ class TickMessage < Bubbletea::Message; end
13
+
14
+ class App
15
+ include Bubbletea::Model
16
+
17
+ FPS = 30
18
+ GRID_WIDTH = 60
19
+ GRID_HEIGHT = 20
20
+
21
+ def initialize(project_dir:, sound: true)
22
+ @project_dir = project_dir
23
+ @sound = sound
24
+ @registry = Agents::Registry.new
25
+ @grid = Office::Grid.new(width: GRID_WIDTH, height: GRID_HEIGHT)
26
+ @pathfinder = Office::Pathfinder.new(@grid)
27
+ @renderer = Rendering::Renderer.new(width: GRID_WIDTH, height: GRID_HEIGHT)
28
+ @notification = Notification.new(sound_enabled: sound)
29
+ @frame_cycle = Animation::FrameCycle.new(frame_count: 2, frames_per_tick: 15)
30
+ @event_queue = Queue.new
31
+ @watcher = nil
32
+ @width = GRID_WIDTH
33
+ @height = GRID_HEIGHT
34
+ end
35
+
36
+ def init
37
+ @watcher = Transcript::Watcher.new(@project_dir, @event_queue)
38
+ @watcher.start
39
+
40
+ [self, schedule_tick]
41
+ end
42
+
43
+ def update(message)
44
+ case message
45
+ when Bubbletea::KeyMessage
46
+ handle_key(message)
47
+ when Bubbletea::WindowSizeMessage
48
+ @width = message.width
49
+ @height = message.height
50
+ grid_w = [@width, GRID_WIDTH].min
51
+ grid_h = [message.height - 4, GRID_HEIGHT].min
52
+ @grid = Office::Grid.new(width: grid_w, height: grid_h)
53
+ @grid.layout_for(@registry.count)
54
+ @pathfinder = Office::Pathfinder.new(@grid)
55
+ @renderer = Rendering::Renderer.new(width: @grid.width, height: @grid.height)
56
+ [self, nil]
57
+ when TickMessage
58
+ handle_tick
59
+ else
60
+ [self, nil]
61
+ end
62
+ end
63
+
64
+ def view
65
+ agents = []
66
+ @registry.each { |a| agents << a }
67
+ @renderer.render(grid: @grid, agents: agents, frame: @frame_cycle.frame)
68
+ end
69
+
70
+ private
71
+
72
+ def handle_key(message)
73
+ case message.to_s
74
+ when "q", "ctrl+c"
75
+ @watcher&.stop
76
+ [self, Bubbletea.quit]
77
+ else
78
+ [self, nil]
79
+ end
80
+ end
81
+
82
+ def handle_tick
83
+ while (event = @event_queue.pop(true) rescue nil)
84
+ process_event(event)
85
+ end
86
+
87
+ @frame_cycle.advance
88
+
89
+ [self, schedule_tick]
90
+ end
91
+
92
+ def process_event(event)
93
+ case event
94
+ when Transcript::AgentCreated
95
+ desk_pos = next_desk_position
96
+ @registry.add(event.session_id, desk_pos)
97
+ @grid.layout_for(@registry.count)
98
+ @pathfinder = Office::Pathfinder.new(@grid)
99
+ reassign_desk_positions
100
+
101
+ when Transcript::ToolStart
102
+ agent = @registry.get(event.session_id)
103
+ agent&.tool_started(event.tool_id, event.tool_name, event.status_text)
104
+
105
+ when Transcript::ToolDone
106
+ agent = @registry.get(event.session_id)
107
+ agent&.tool_done(event.tool_id)
108
+
109
+ when Transcript::TurnEnd
110
+ agent = @registry.get(event.session_id)
111
+ if agent
112
+ agent.turn_ended
113
+ @notification.agent_turn_ended(event.session_id)
114
+ end
115
+
116
+ when Transcript::SubAgentToolStart
117
+ agent = @registry.get(event.session_id)
118
+ agent&.sub_agent_tool_started(
119
+ event.parent_tool_id, event.tool_id,
120
+ event.tool_name, event.status_text
121
+ )
122
+
123
+ when Transcript::SubAgentToolDone
124
+ agent = @registry.get(event.session_id)
125
+ agent&.sub_agent_tool_done(event.parent_tool_id, event.tool_id)
126
+ end
127
+ end
128
+
129
+ def next_desk_position
130
+ count = @registry.count
131
+ [3 + (count % 3) * 13, 3 + (count / 3) * 7]
132
+ end
133
+
134
+ def reassign_desk_positions
135
+ index = 0
136
+ @registry.each do |agent|
137
+ if index < @grid.desks.length
138
+ desk = @grid.desks[index]
139
+ desk.assign(agent.session_id)
140
+ end
141
+ index += 1
142
+ end
143
+ end
144
+
145
+ def schedule_tick
146
+ Bubbletea.tick(1.0 / FPS) { TickMessage.new }
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,47 @@
1
+ require_relative "transcript/watcher"
2
+
3
+ module ClaudeOffice
4
+ class CLI
5
+ def self.run(args)
6
+ if args.include?("--version")
7
+ puts "claude-office #{ClaudeOffice::VERSION}"
8
+ return
9
+ end
10
+
11
+ if args.include?("--help")
12
+ puts <<~HELP
13
+ Usage: claude-office [PROJECT_PATH] [OPTIONS]
14
+
15
+ Watch Claude Code sessions as animated characters in a terminal office.
16
+
17
+ Arguments:
18
+ PROJECT_PATH Path to project (default: current directory)
19
+
20
+ Options:
21
+ --no-sound Disable terminal bell notifications
22
+ --version Show version
23
+ --help Show this help
24
+ HELP
25
+ return
26
+ end
27
+
28
+ project_path = args.reject { |a| a.start_with?("--") }.first || Dir.pwd
29
+ sound = !args.include?("--no-sound")
30
+
31
+ project_dir = Transcript::Watcher.claude_project_dir(project_path)
32
+
33
+ unless Dir.exist?(project_dir)
34
+ $stderr.puts "Error: No Claude Code data found for #{project_path}"
35
+ $stderr.puts "Expected: #{project_dir}"
36
+ $stderr.puts ""
37
+ $stderr.puts "Make sure you've run Claude Code in that directory at least once."
38
+ exit 1
39
+ end
40
+
41
+ require_relative "app"
42
+
43
+ app = App.new(project_dir: project_dir, sound: sound)
44
+ Bubbletea.run(app, alt_screen: true, mouse_cell_motion: true)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,22 @@
1
+ module ClaudeOffice
2
+ class Notification
3
+ def initialize(sound_enabled: true)
4
+ @sound_enabled = sound_enabled
5
+ end
6
+
7
+ def agent_waiting(session_id)
8
+ bell if @sound_enabled
9
+ end
10
+
11
+ def agent_turn_ended(session_id)
12
+ bell if @sound_enabled
13
+ end
14
+
15
+ private
16
+
17
+ def bell
18
+ print "\a"
19
+ $stdout.flush
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,25 @@
1
+ module ClaudeOffice
2
+ module Office
3
+ class Desk
4
+ attr_reader :position, :chair_position, :assigned_session_id
5
+
6
+ def initialize(position:, chair_position:)
7
+ @position = position
8
+ @chair_position = chair_position
9
+ @assigned_session_id = nil
10
+ end
11
+
12
+ def assign(session_id)
13
+ @assigned_session_id = session_id
14
+ end
15
+
16
+ def unassign
17
+ @assigned_session_id = nil
18
+ end
19
+
20
+ def assigned?
21
+ !@assigned_session_id.nil?
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,66 @@
1
+ require_relative "desk"
2
+
3
+ module ClaudeOffice
4
+ module Office
5
+ class Grid
6
+ DESKS_PER_ROW = 3
7
+ DESK_WIDTH = 7
8
+ DESK_HEIGHT = 3
9
+ DESK_SPACING_X = 6
10
+ DESK_SPACING_Y = 7
11
+ MARGIN_X = 3
12
+ MARGIN_Y = 2
13
+
14
+ attr_reader :width, :height, :desks, :tiles
15
+
16
+ def initialize(width:, height:)
17
+ @width = width
18
+ @height = height
19
+ @desks = []
20
+ @tiles = Array.new(height) { Array.new(width, :floor) }
21
+ place_walls
22
+ end
23
+
24
+ def layout_for(agent_count)
25
+ @desks.clear
26
+ return if agent_count == 0
27
+
28
+ rows = (agent_count.to_f / DESKS_PER_ROW).ceil
29
+ agent_index = 0
30
+
31
+ rows.times do |row|
32
+ desks_in_row = [DESKS_PER_ROW, agent_count - agent_index].min
33
+ row_y = MARGIN_Y + 1 + row * DESK_SPACING_Y
34
+
35
+ desks_in_row.times do |col|
36
+ desk_x = MARGIN_X + 1 + col * (DESK_WIDTH + DESK_SPACING_X)
37
+ desk_pos = [desk_x, row_y]
38
+ chair_pos = [desk_x + DESK_WIDTH / 2, row_y + DESK_HEIGHT + 1]
39
+
40
+ @desks << Desk.new(position: desk_pos, chair_position: chair_pos)
41
+ agent_index += 1
42
+ end
43
+ end
44
+ end
45
+
46
+ def walkable?(x, y)
47
+ return false if x < 0 || y < 0 || x >= @width || y >= @height
48
+
49
+ @tiles[y][x] == :floor
50
+ end
51
+
52
+ private
53
+
54
+ def place_walls
55
+ @width.times do |x|
56
+ @tiles[0][x] = :wall
57
+ @tiles[@height - 1][x] = :wall
58
+ end
59
+ @height.times do |y|
60
+ @tiles[y][0] = :wall
61
+ @tiles[y][@width - 1] = :wall
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,53 @@
1
+ module ClaudeOffice
2
+ module Office
3
+ class Pathfinder
4
+ DIRECTIONS = [[0, -1], [0, 1], [-1, 0], [1, 0]].freeze
5
+
6
+ def initialize(grid)
7
+ @grid = grid
8
+ end
9
+
10
+ def find_path(start, goal)
11
+ return [start] if start == goal
12
+ return nil unless @grid.walkable?(start[0], start[1])
13
+ return nil unless @grid.walkable?(goal[0], goal[1])
14
+
15
+ queue = [start]
16
+ came_from = { start => nil }
17
+
18
+ until queue.empty?
19
+ current = queue.shift
20
+
21
+ if current == goal
22
+ return reconstruct_path(came_from, goal)
23
+ end
24
+
25
+ DIRECTIONS.each do |dx, dy|
26
+ neighbor = [current[0] + dx, current[1] + dy]
27
+ next if came_from.key?(neighbor)
28
+ next unless @grid.walkable?(neighbor[0], neighbor[1])
29
+
30
+ came_from[neighbor] = current
31
+ queue << neighbor
32
+ end
33
+ end
34
+
35
+ nil
36
+ end
37
+
38
+ private
39
+
40
+ def reconstruct_path(came_from, goal)
41
+ path = [goal]
42
+ current = goal
43
+
44
+ while came_from[current]
45
+ current = came_from[current]
46
+ path.unshift(current)
47
+ end
48
+
49
+ path
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,122 @@
1
+ require "lipgloss"
2
+ require_relative "sprites"
3
+ require_relative "theme"
4
+
5
+ module ClaudeOffice
6
+ module Rendering
7
+ class Renderer
8
+ def initialize(width:, height:)
9
+ @width = width
10
+ @height = height
11
+ end
12
+
13
+ def render(grid:, agents:, frame:)
14
+ title = render_title(grid)
15
+ office = render_office(grid, agents, frame)
16
+ status = render_status_bar(agents)
17
+
18
+ Lipgloss.join_vertical(:left, title, office, status)
19
+ end
20
+
21
+ private
22
+
23
+ def render_title(grid)
24
+ Theme::TITLE_STYLE
25
+ .width(@width)
26
+ .render("claude-office")
27
+ end
28
+
29
+ def render_office(grid, agents, frame)
30
+ lines = []
31
+
32
+ grid.height.times do |y|
33
+ line = ""
34
+ grid.width.times do |x|
35
+ case grid.tiles[y][x]
36
+ when :wall
37
+ line += Theme::WALL_STYLE.render(Sprites::WALL_CHAR)
38
+ else
39
+ line += Theme::FLOOR_STYLE.render(Sprites::FLOOR_CHAR)
40
+ end
41
+ end
42
+ lines << line
43
+ end
44
+
45
+ office_str = lines.join("\n")
46
+
47
+ grid.desks.each do |desk|
48
+ desk_str = Sprites::DESK.map { |row| Theme::DESK_STYLE.render(row) }.join("\n")
49
+ office_str = overlay(office_str, desk_str, desk.position[0], desk.position[1])
50
+ end
51
+
52
+ agents.each do |agent|
53
+ face = Sprites.face_for(agent.animation, frame: frame)
54
+ style = Theme.agent_style(agent.state)
55
+ agent_str = style.render(face)
56
+
57
+ unless agent.status_text.empty?
58
+ status = Theme::STATUS_TEXT_STYLE.render("\"#{agent.status_text}\"")
59
+ agent_str = Lipgloss.join_vertical(:center, agent_str, status)
60
+ end
61
+
62
+ agent.sub_agents.each_value do |sub|
63
+ sub_face = Sprites.sub_face_for(sub.animation)
64
+ sub_line = Theme::SUB_AGENT_STYLE.render("└─ #{sub_face}")
65
+ unless sub.status_text.empty?
66
+ sub_line += " " + Theme::STATUS_TEXT_STYLE.render("\"#{sub.status_text}\"")
67
+ end
68
+ agent_str = Lipgloss.join_vertical(:left, agent_str, sub_line)
69
+ end
70
+
71
+ if agent.state == :waiting
72
+ bubble = Theme::SPEECH_BUBBLE_STYLE.render("Needs input!")
73
+ agent_str = Lipgloss.join_vertical(:center, bubble, agent_str)
74
+ end
75
+
76
+ pos = agent.desk_position
77
+ char_x = pos[0] + 1
78
+ char_y = pos[1] + Sprites::DESK.length + 1
79
+ office_str = overlay(office_str, agent_str, char_x, char_y)
80
+ end
81
+
82
+ office_str
83
+ end
84
+
85
+ def render_status_bar(agents)
86
+ parts = agents.map do |agent|
87
+ state_str = agent.state.to_s
88
+ subs = agent.sub_agents.size
89
+ sub_info = subs > 0 ? " (#{subs} sub#{"s" if subs > 1})" : ""
90
+ "Agent: #{state_str}#{sub_info}"
91
+ end
92
+
93
+ parts << "q: quit"
94
+ bar_text = parts.join(" │ ")
95
+
96
+ Theme::STATUS_BAR_STYLE
97
+ .width(@width)
98
+ .render(bar_text)
99
+ end
100
+
101
+ def overlay(base, overlay_str, x, y)
102
+ base_lines = base.split("\n")
103
+ overlay_lines = overlay_str.split("\n")
104
+
105
+ overlay_lines.each_with_index do |oline, i|
106
+ target_y = y + i
107
+ next if target_y < 0 || target_y >= base_lines.length
108
+
109
+ base_line = base_lines[target_y]
110
+ visible_len = Lipgloss.width(oline)
111
+
112
+ before = base_line[0...x] || ""
113
+ after_start = x + visible_len
114
+ after = base_line[after_start..] || ""
115
+ base_lines[target_y] = before + oline + after
116
+ end
117
+
118
+ base_lines.join("\n")
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,40 @@
1
+ module ClaudeOffice
2
+ module Rendering
3
+ module Sprites
4
+ FACES = {
5
+ idle: "(o_o)",
6
+ typing: "(o.o)~",
7
+ reading: "(o.O)",
8
+ running: "(>.<)",
9
+ waiting: "(-.-)zzZ",
10
+ walking: ["(o_o)/", "(o_o)\\"],
11
+ }.freeze
12
+
13
+ SUB_AGENT_FACES = {
14
+ idle: "(o_o)",
15
+ typing: "(o.o)",
16
+ reading: "(o.O)",
17
+ running: "(>.<)",
18
+ waiting: "(-.-)",
19
+ }.freeze
20
+
21
+ DESK = [
22
+ "┌─────┐",
23
+ "│ ▒▒▒ │",
24
+ "└──┬──┘",
25
+ ].freeze
26
+
27
+ FLOOR_CHAR = "░"
28
+ WALL_CHAR = "█"
29
+
30
+ def self.face_for(animation, frame: 0)
31
+ sprite = FACES.fetch(animation, FACES[:idle])
32
+ sprite.is_a?(Array) ? sprite[frame % sprite.length] : sprite
33
+ end
34
+
35
+ def self.sub_face_for(animation)
36
+ SUB_AGENT_FACES.fetch(animation, SUB_AGENT_FACES[:idle])
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,59 @@
1
+ require "lipgloss"
2
+
3
+ module ClaudeOffice
4
+ module Rendering
5
+ module Theme
6
+ FLOOR_STYLE = Lipgloss::Style.new
7
+ .foreground("#555555")
8
+ .background("#2D2D2D")
9
+
10
+ WALL_STYLE = Lipgloss::Style.new
11
+ .foreground("#777777")
12
+ .background("#444444")
13
+
14
+ DESK_STYLE = Lipgloss::Style.new
15
+ .foreground("#8B6914")
16
+
17
+ ACTIVE_AGENT_STYLE = Lipgloss::Style.new
18
+ .foreground("#00D4AA")
19
+ .bold(true)
20
+
21
+ WAITING_AGENT_STYLE = Lipgloss::Style.new
22
+ .foreground("#FFD700")
23
+
24
+ IDLE_AGENT_STYLE = Lipgloss::Style.new
25
+ .foreground("#AAAAAA")
26
+
27
+ STATUS_TEXT_STYLE = Lipgloss::Style.new
28
+ .foreground("#888888")
29
+ .italic(true)
30
+
31
+ SUB_AGENT_STYLE = Lipgloss::Style.new
32
+ .foreground("#77AADD")
33
+
34
+ SPEECH_BUBBLE_STYLE = Lipgloss::Style.new
35
+ .border(:rounded)
36
+ .border_foreground("#874BFD")
37
+ .padding(0, 1)
38
+
39
+ TITLE_STYLE = Lipgloss::Style.new
40
+ .bold(true)
41
+ .foreground("#FFFFFF")
42
+ .background("#333333")
43
+ .padding(0, 1)
44
+
45
+ STATUS_BAR_STYLE = Lipgloss::Style.new
46
+ .foreground("#CCCCCC")
47
+ .background("#333333")
48
+ .padding(0, 1)
49
+
50
+ def self.agent_style(state)
51
+ case state
52
+ when :working then ACTIVE_AGENT_STYLE
53
+ when :waiting then WAITING_AGENT_STYLE
54
+ else IDLE_AGENT_STYLE
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,71 @@
1
+ module ClaudeOffice
2
+ module Transcript
3
+ class AgentCreated
4
+ attr_reader :session_id, :jsonl_path
5
+
6
+ def initialize(session_id:, jsonl_path:)
7
+ @session_id = session_id
8
+ @jsonl_path = jsonl_path
9
+ end
10
+ end
11
+
12
+ class ToolStart
13
+ attr_reader :session_id, :tool_id, :tool_name, :status_text
14
+
15
+ def initialize(session_id:, tool_id:, tool_name:, status_text:)
16
+ @session_id = session_id
17
+ @tool_id = tool_id
18
+ @tool_name = tool_name
19
+ @status_text = status_text
20
+ end
21
+ end
22
+
23
+ class ToolDone
24
+ attr_reader :session_id, :tool_id
25
+
26
+ def initialize(session_id:, tool_id:)
27
+ @session_id = session_id
28
+ @tool_id = tool_id
29
+ end
30
+ end
31
+
32
+ class TurnEnd
33
+ attr_reader :session_id, :duration_ms
34
+
35
+ def initialize(session_id:, duration_ms:)
36
+ @session_id = session_id
37
+ @duration_ms = duration_ms
38
+ end
39
+ end
40
+
41
+ class SubAgentToolStart
42
+ attr_reader :session_id, :parent_tool_id, :tool_id, :tool_name, :status_text
43
+
44
+ def initialize(session_id:, parent_tool_id:, tool_id:, tool_name:, status_text:)
45
+ @session_id = session_id
46
+ @parent_tool_id = parent_tool_id
47
+ @tool_id = tool_id
48
+ @tool_name = tool_name
49
+ @status_text = status_text
50
+ end
51
+ end
52
+
53
+ class SubAgentToolDone
54
+ attr_reader :session_id, :parent_tool_id, :tool_id
55
+
56
+ def initialize(session_id:, parent_tool_id:, tool_id:)
57
+ @session_id = session_id
58
+ @parent_tool_id = parent_tool_id
59
+ @tool_id = tool_id
60
+ end
61
+ end
62
+
63
+ class TextOnly
64
+ attr_reader :session_id
65
+
66
+ def initialize(session_id:)
67
+ @session_id = session_id
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,183 @@
1
+ require "json"
2
+ require_relative "events"
3
+
4
+ module ClaudeOffice
5
+ module Transcript
6
+ class Parser
7
+ BASH_COMMAND_MAX_LENGTH = 40
8
+ TASK_DESCRIPTION_MAX_LENGTH = 40
9
+
10
+ def initialize(session_id)
11
+ @session_id = session_id
12
+ @active_tools = {}
13
+ end
14
+
15
+ def register_active_tool(tool_id, tool_name)
16
+ @active_tools[tool_id] = tool_name
17
+ end
18
+
19
+ def unregister_active_tool(tool_id)
20
+ @active_tools.delete(tool_id)
21
+ end
22
+
23
+ def parse_line(line)
24
+ record = JSON.parse(line)
25
+ type = record["type"]
26
+
27
+ case type
28
+ when "assistant"
29
+ parse_assistant(record)
30
+ when "user"
31
+ parse_user(record)
32
+ when "system"
33
+ parse_system(record)
34
+ when "progress"
35
+ parse_progress(record)
36
+ else
37
+ []
38
+ end
39
+ rescue JSON::ParserError
40
+ []
41
+ end
42
+
43
+ private
44
+
45
+ def parse_assistant(record)
46
+ content = record.dig("message", "content")
47
+ return [] unless content.is_a?(Array)
48
+
49
+ tool_uses = content.select { |b| b["type"] == "tool_use" }
50
+
51
+ if tool_uses.any?
52
+ tool_uses.map do |block|
53
+ tool_name = block["name"] || ""
54
+ input = block["input"] || {}
55
+ status = format_tool_status(tool_name, input)
56
+
57
+ register_active_tool(block["id"], tool_name)
58
+
59
+ ToolStart.new(
60
+ session_id: @session_id,
61
+ tool_id: block["id"],
62
+ tool_name: tool_name,
63
+ status_text: status
64
+ )
65
+ end
66
+ elsif content.any? { |b| b["type"] == "text" }
67
+ [TextOnly.new(session_id: @session_id)]
68
+ else
69
+ []
70
+ end
71
+ end
72
+
73
+ def parse_user(record)
74
+ content = record.dig("message", "content")
75
+
76
+ if content.is_a?(Array)
77
+ tool_results = content.select { |b| b["type"] == "tool_result" }
78
+
79
+ tool_results.map do |block|
80
+ tool_id = block["tool_use_id"]
81
+ unregister_active_tool(tool_id)
82
+ ToolDone.new(session_id: @session_id, tool_id: tool_id)
83
+ end
84
+ else
85
+ []
86
+ end
87
+ end
88
+
89
+ def parse_system(record)
90
+ return [] unless record["subtype"] == "turn_duration"
91
+
92
+ [TurnEnd.new(
93
+ session_id: @session_id,
94
+ duration_ms: record["durationMs"]
95
+ )]
96
+ end
97
+
98
+ def parse_progress(record)
99
+ parent_tool_id = record["parentToolUseID"]
100
+ return [] unless parent_tool_id
101
+ return [] unless @active_tools[parent_tool_id] == "Task"
102
+
103
+ data = record["data"]
104
+ return [] unless data.is_a?(Hash)
105
+
106
+ message = data.dig("message")
107
+ return [] unless message.is_a?(Hash)
108
+
109
+ msg_type = message["type"]
110
+ inner_content = message.dig("message", "content")
111
+ return [] unless inner_content.is_a?(Array)
112
+
113
+ case msg_type
114
+ when "assistant"
115
+ inner_content.select { |b| b["type"] == "tool_use" }.map do |block|
116
+ tool_name = block["name"] || ""
117
+ input = block["input"] || {}
118
+ status = format_tool_status(tool_name, input)
119
+
120
+ SubAgentToolStart.new(
121
+ session_id: @session_id,
122
+ parent_tool_id: parent_tool_id,
123
+ tool_id: block["id"],
124
+ tool_name: tool_name,
125
+ status_text: status
126
+ )
127
+ end
128
+ when "user"
129
+ inner_content.select { |b| b["type"] == "tool_result" }.map do |block|
130
+ SubAgentToolDone.new(
131
+ session_id: @session_id,
132
+ parent_tool_id: parent_tool_id,
133
+ tool_id: block["tool_use_id"]
134
+ )
135
+ end
136
+ else
137
+ []
138
+ end
139
+ end
140
+
141
+ def format_tool_status(tool_name, input)
142
+ case tool_name
143
+ when "Read"
144
+ "Reading #{basename(input["file_path"])}"
145
+ when "Edit"
146
+ "Editing #{basename(input["file_path"])}"
147
+ when "Write"
148
+ "Writing #{basename(input["file_path"])}"
149
+ when "Bash"
150
+ cmd = (input["command"] || "").to_s
151
+ truncated = cmd.length > BASH_COMMAND_MAX_LENGTH ? "#{cmd[0...BASH_COMMAND_MAX_LENGTH]}…" : cmd
152
+ "Running: #{truncated}"
153
+ when "Glob"
154
+ "Searching files"
155
+ when "Grep"
156
+ "Searching code"
157
+ when "WebFetch"
158
+ "Fetching web content"
159
+ when "WebSearch"
160
+ "Searching the web"
161
+ when "Task"
162
+ desc = (input["description"] || "").to_s
163
+ truncated = desc.length > TASK_DESCRIPTION_MAX_LENGTH ? "#{desc[0...TASK_DESCRIPTION_MAX_LENGTH]}…" : desc
164
+ truncated.empty? ? "Running subtask" : "Subtask: #{truncated}"
165
+ when "AskUserQuestion"
166
+ "Waiting for your answer"
167
+ when "EnterPlanMode"
168
+ "Planning"
169
+ when "NotebookEdit"
170
+ "Editing notebook"
171
+ else
172
+ "Using #{tool_name}"
173
+ end
174
+ end
175
+
176
+ def basename(path)
177
+ return "" unless path.is_a?(String)
178
+
179
+ File.basename(path)
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,99 @@
1
+ require_relative "parser"
2
+ require_relative "events"
3
+
4
+ module ClaudeOffice
5
+ module Transcript
6
+ class Watcher
7
+ POLL_INTERVAL = 2.0
8
+
9
+ attr_reader :project_dir
10
+
11
+ def initialize(project_dir, event_queue)
12
+ @project_dir = project_dir
13
+ @event_queue = event_queue
14
+ @known_files = {}
15
+ @running = false
16
+ @thread = nil
17
+ end
18
+
19
+ def self.project_slug(path)
20
+ path.gsub("/", "-")
21
+ end
22
+
23
+ def self.claude_project_dir(project_path)
24
+ slug = project_slug(File.expand_path(project_path))
25
+ File.join(Dir.home, ".claude", "projects", slug)
26
+ end
27
+
28
+ def start
29
+ @running = true
30
+ @thread = Thread.new { run_loop }
31
+ @thread.abort_on_exception = true
32
+ end
33
+
34
+ def stop
35
+ @running = false
36
+ @thread&.join(2)
37
+ end
38
+
39
+ def scan_once
40
+ return unless Dir.exist?(@project_dir)
41
+
42
+ Dir.glob(File.join(@project_dir, "*.jsonl")).each do |path|
43
+ next if @known_files.key?(path)
44
+
45
+ session_id = File.basename(path, ".jsonl")
46
+ parser = Parser.new(session_id)
47
+
48
+ @known_files[path] = {
49
+ parser: parser,
50
+ offset: 0,
51
+ line_buffer: ""
52
+ }
53
+
54
+ @event_queue.push(AgentCreated.new(
55
+ session_id: session_id,
56
+ jsonl_path: path
57
+ ))
58
+ end
59
+ end
60
+
61
+ def read_new_lines
62
+ @known_files.each do |path, state|
63
+ read_file_lines(path, state)
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def run_loop
70
+ while @running
71
+ scan_once
72
+ read_new_lines
73
+ sleep(POLL_INTERVAL)
74
+ end
75
+ end
76
+
77
+ def read_file_lines(path, state)
78
+ return unless File.exist?(path)
79
+
80
+ size = File.size(path)
81
+ return if size <= state[:offset]
82
+
83
+ data = File.binread(path, size - state[:offset], state[:offset])
84
+ state[:offset] = size
85
+
86
+ text = state[:line_buffer] + data.force_encoding("UTF-8")
87
+ lines = text.split("\n", -1)
88
+ state[:line_buffer] = lines.pop || ""
89
+
90
+ lines.each do |line|
91
+ next if line.strip.empty?
92
+
93
+ events = state[:parser].parse_line(line)
94
+ events.each { |e| @event_queue.push(e) }
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,3 @@
1
+ module ClaudeOffice
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,19 @@
1
+ require_relative "claude_office/version"
2
+ require_relative "claude_office/transcript/events"
3
+ require_relative "claude_office/transcript/parser"
4
+ require_relative "claude_office/transcript/watcher"
5
+ require_relative "claude_office/agents/agent"
6
+ require_relative "claude_office/agents/sub_agent"
7
+ require_relative "claude_office/agents/registry"
8
+ require_relative "claude_office/office/desk"
9
+ require_relative "claude_office/office/grid"
10
+ require_relative "claude_office/office/pathfinder"
11
+ require_relative "claude_office/rendering/sprites"
12
+ require_relative "claude_office/rendering/theme"
13
+ require_relative "claude_office/rendering/renderer"
14
+ require_relative "claude_office/animation/spring_mover"
15
+ require_relative "claude_office/animation/frame_cycle"
16
+ require_relative "claude_office/notification"
17
+
18
+ module ClaudeOffice
19
+ end
metadata ADDED
@@ -0,0 +1,138 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: claude-office
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Fernando Ruiz
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bubbletea
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: lipgloss
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: harmonica
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rspec
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.13'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.13'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rake
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '13.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '13.0'
82
+ description: Watch Claude Code sessions come alive as kaomoji characters in a terminal
83
+ office. Characters walk, type, read, and wait based on real-time JSONL transcript
84
+ data.
85
+ email:
86
+ - fruizg0302@users.noreply.github.com
87
+ executables:
88
+ - claude-office
89
+ extensions: []
90
+ extra_rdoc_files: []
91
+ files:
92
+ - LICENSE
93
+ - bin/claude-office
94
+ - lib/claude_office.rb
95
+ - lib/claude_office/agents/agent.rb
96
+ - lib/claude_office/agents/registry.rb
97
+ - lib/claude_office/agents/sub_agent.rb
98
+ - lib/claude_office/animation/frame_cycle.rb
99
+ - lib/claude_office/animation/spring_mover.rb
100
+ - lib/claude_office/app.rb
101
+ - lib/claude_office/cli.rb
102
+ - lib/claude_office/notification.rb
103
+ - lib/claude_office/office/desk.rb
104
+ - lib/claude_office/office/grid.rb
105
+ - lib/claude_office/office/pathfinder.rb
106
+ - lib/claude_office/rendering/renderer.rb
107
+ - lib/claude_office/rendering/sprites.rb
108
+ - lib/claude_office/rendering/theme.rb
109
+ - lib/claude_office/transcript/events.rb
110
+ - lib/claude_office/transcript/parser.rb
111
+ - lib/claude_office/transcript/watcher.rb
112
+ - lib/claude_office/version.rb
113
+ homepage: https://github.com/fruizg0302/claude-office
114
+ licenses:
115
+ - MIT
116
+ metadata:
117
+ homepage_uri: https://github.com/fruizg0302/claude-office
118
+ source_code_uri: https://github.com/fruizg0302/claude-office
119
+ changelog_uri: https://github.com/fruizg0302/claude-office/blob/master/CHANGELOG.md
120
+ rubygems_mfa_required: 'true'
121
+ rdoc_options: []
122
+ require_paths:
123
+ - lib
124
+ required_ruby_version: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: 3.2.0
129
+ required_rubygems_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ requirements: []
135
+ rubygems_version: 3.6.8
136
+ specification_version: 4
137
+ summary: A TUI companion for Claude Code — animated kaomoji agents in a virtual office
138
+ test_files: []