Skip to content
Open
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
23 changes: 23 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Composability Rules for Cir

## Core Principles

0. **Simplicity**: A code change that makes the code harder to read always needs to be justified by adding some other user or clear development value.

1. **Single Source of Truth**: Maintain all application state in `AppState`. Never store state in individual components that should be shared.

2. **State Changes Through `updateState` Only**: All state mutations should flow through the `updateState` function to ensure consistent state transitions and automatic persistence.

3. **Pure Rendering Components**: Components should be "dumb renderers" that transform data into UI without maintaining their own complex state.

4. **Consistent Component APIs**: All components should follow the same pattern:
- Constructor that initializes the component
- `Render` method that updates the component's view based on current state

5. **Unidirectional Data Flow**: State flows down through component hierarchy; events flow up to trigger state changes.

6. **Clear Component Boundaries**: Components should have well-defined responsibilities and minimal dependencies on other components.

7. **Event Handlers in Application Layer**: Keep component event handlers in the application layer where they can trigger state updates.

8. **Separation of UI and Logic**: UI components should handle display only; business logic belongs in the application layer.
17 changes: 17 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [

{
"name": "Go!",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}",
"console": "integratedTerminal",
}
]
}
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ For a separate session, use the `-session` flag like this: `cir -session my-sess

# Key bindings

- Ctrl-o - Manage context
- Ctrl-s - Submit message
- (Shift-)Tab - Toggle focus between input and chat history
Type '?' to get key bindings.

# Run from this repo

Expand Down
182 changes: 80 additions & 102 deletions application.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,15 @@ import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"github.com/worldsayshi/cir/internal/components"
"github.com/worldsayshi/cir/internal/state"
"github.com/worldsayshi/cir/internal/storage"
"github.com/worldsayshi/cir/internal/types"
)

// AppState holds all application state
type AppState struct {
workingSession *types.WorkingSession
sessionFile string
isProcessing bool
}

// CirApplication manages UI components and application lifecycle
type CirApplication struct {
*tview.Application
state *AppState
appState *state.AppState
chatHistory *components.ChatHistory
inputArea *components.InputArea
contextBar *components.ContextBar
Expand All @@ -45,11 +39,11 @@ func NewCirApplication(sessionFile string) *CirApplication {
panic(fmt.Sprintf("Error loading session from file: %v\n%v", sessionFile, err))
}

state := &AppState{
workingSession: workingSession,
sessionFile: sessionFile,
isProcessing: false,
}
// Initialize centralized AppState
appState := state.NewAppState(workingSession, sessionFile)

// Set up automatic persistence
appState.AddPersistenceSubscriber()

// Initialize UI components
chatHistory := components.NewChatHistory(nil) // Will be populated during render
Expand All @@ -58,12 +52,11 @@ func NewCirApplication(sessionFile string) *CirApplication {
pages := tview.NewPages()

cirApp := &CirApplication{
Application: tview.NewApplication(),
state: state,
chatHistory: chatHistory,
inputArea: inputArea,
contextBar: contextBar,
// helpPopup: helpPopup,
Application: tview.NewApplication(),
appState: appState,
chatHistory: chatHistory,
inputArea: inputArea,
contextBar: contextBar,
rootContainer: tview.NewFlex().SetDirection(tview.FlexRow),
pages: pages,
}
Expand All @@ -83,27 +76,31 @@ func NewCirApplication(sessionFile string) *CirApplication {

// Set up UI event handlers
inputArea.SetChangedFunc(func() {
cirApp.updateState(func(state *AppState) bool {
state.workingSession.InputText = inputArea.GetText()
return false
appState.Update(func(data *state.StateData) {
data.WorkingSession.InputText = inputArea.GetText()
})
})

inputArea.SetSubmitFunc(cirApp.handleChatSubmit)

// Subscribe to state changes for UI updates
appState.Subscribe(cirApp.render)

// Setup keyboard handlers
cirApp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
// Special case for the '?' key since it's a rune, not a special key
// If the help popup is open, any key press should close it
if cirApp.pages.HasPage("help") {
cirApp.pages.RemovePage("help")
return nil
}

// Special case for the '?' key
if event.Key() == tcell.KeyRune && event.Rune() == '?' {
log.Println("Special case")
cirApp.helpPopup.ShowHelpPopup()
return nil
}
// If the help popup is open, for now, any key press should close it
if cirApp.pages.HasPage("help") {
cirApp.pages.RemovePage("help")
}

// Check for specific key mappings (Tab, Ctrl+E, etc.)
for _, mapping := range cirApp.keyMappings {
if event.Key() == mapping.Key {
log.Println("Key pressed:",
Expand All @@ -113,6 +110,8 @@ func NewCirApplication(sessionFile string) *CirApplication {
return nil
}
}

// Let all other keys pass through to the focused primitive
return event
})

Expand Down Expand Up @@ -154,38 +153,26 @@ func createKeyMappings(cirApp *CirApplication) []types.KeyMapping {
cirApp.editContextFiles()
},
},
// {
// Key: tcell.KeyRune,
// Description: "Show help",
// Action: func() {
// log.Println("Show help")
// cirApp.helpPopup.ShowHelpPopup()
// },
// },
}
}

// updateState applies a state change function and triggers a UI update
func (cirApp *CirApplication) updateState(updateFunc func(*AppState) bool) {
rerender := updateFunc(cirApp.state)
if rerender {
cirApp.render()
}

// Save state changes to disk
if err := storage.SaveWorkingSession(cirApp.state.sessionFile, cirApp.state.workingSession); err != nil {
log.Println("Error saving session:", err)
}
}

// render updates all UI components based on current state
// without triggering further state updates
func (cirApp *CirApplication) render() {
// Get all state data we need without modifying state
workingSession := cirApp.appState.GetWorkingSession()
isProcessing := cirApp.appState.IsCurrentlyProcessing()

// Update UI components with current state
cirApp.chatHistory.Render(workingSession.Messages)
cirApp.contextBar.Render(workingSession.WorkingFiles)

cirApp.chatHistory.Render(cirApp.state.workingSession.Messages)
cirApp.contextBar.Render(cirApp.state.workingSession.WorkingFiles)
cirApp.inputArea.SetText(cirApp.state.workingSession.InputText, false)
// Only update if the text actually changed to prevent loops
if cirApp.inputArea.GetText() != workingSession.InputText {
cirApp.inputArea.SetText(workingSession.InputText, false)
}

cirApp.inputArea.SetDisabled(cirApp.state.isProcessing)
cirApp.inputArea.SetDisabled(isProcessing)

// Set up the layout if it hasn't been done yet
if cirApp.rootContainer.GetItemCount() == 0 {
Expand Down Expand Up @@ -228,6 +215,7 @@ func (cirApp *CirApplication) cycleFocus(elements []tview.Primitive, reverse boo
}
}

// openSessionFile loads a new session file and updates the app state
func (cirApp *CirApplication) openSessionFile() {
sessionFindingCommand := `(dir=$(pwd); while [ "$dir" != "/" ]; do find "$dir" -maxdepth 1 \( -name "*.yaml" -o -name "*.yml" \) -exec grep -l "^kind: WorkingSession" {} \; 2>/dev/null; if [ -d "$dir/.cir" ]; then find "$dir/.cir" -maxdepth 1 \( -name "*.yaml" -o -name "*.yml" \) -exec grep -l "^kind: WorkingSession" {} \; 2>/dev/null; fi; dir=$(dirname "$dir"); done)`
tmuxSessionFindingCommand := sessionFindingCommand + ` | fzf-tmux -h -m`
Expand All @@ -252,19 +240,19 @@ func (cirApp *CirApplication) openSessionFile() {
}

// Update state with new session
cirApp.updateState(func(state *AppState) bool {
state.workingSession = newWorkingSession
state.sessionFile = filePath
return true
cirApp.appState.Update(func(data *state.StateData) {
data.WorkingSession = newWorkingSession
data.SessionFile = filePath
})
}

func (cirApp *CirApplication) editContextFiles() {
cmd := "find . -type f -not -path '*/.*' | fzf-tmux -h -m"
cmd := "find . -type f -not -path '*/.*' | fzf-tmux -h -m | cat"
out, err := exec.Command(
"bash", "-c", cmd,
).CombinedOutput()
if err != nil {
log.Println("Error executing command:", cmd)
log.Println(err)
return
}
Expand All @@ -278,70 +266,64 @@ func (cirApp *CirApplication) editContextFiles() {
}
}

cirApp.updateState(func(state *AppState) bool {
state.workingSession.WorkingFiles = selectedWorkingFiles
return true
cirApp.appState.Update(func(data *state.StateData) {
data.WorkingSession.WorkingFiles = selectedWorkingFiles
})
}

func (cirApp *CirApplication) Run() error {
if err := cirApp.Application.Run(); err != nil {
return err
}

// Final save before exiting
if err := storage.SaveWorkingSession(cirApp.state.sessionFile, cirApp.state.workingSession); err != nil {
log.Println("Error saving session:", err)
}

return nil
return cirApp.Application.Run()
}

func (cirApp *CirApplication) handleChatSubmit(text string) {
if text == "" {
return
}

cirApp.updateState(func(state *AppState) bool {
cirApp.appState.Update(func(data *state.StateData) {
// Initialize with system message if needed
if len(state.workingSession.Messages) == 0 {
state.workingSession.Messages = append(state.workingSession.Messages,
if len(data.WorkingSession.Messages) == 0 {
data.WorkingSession.Messages = append(data.WorkingSession.Messages,
createSystemMessage())
}

filesToSubmit := getFilesToSubmitWithChecksums(state.workingSession.WorkingFiles)
// Get checksums without risking deadlocks, all within the update function
filesToSubmit := getFilesToSubmitWithChecksums(data.WorkingSession.WorkingFiles)
userMessage := prepareUserMessage(filesToSubmit, text)

// Add user message
state.workingSession.Messages = append(
state.workingSession.Messages, userMessage,
data.WorkingSession.Messages = append(
data.WorkingSession.Messages, userMessage,
)

// Update file checksums
for i, wf := range state.workingSession.WorkingFiles {
updatedWorkingFiles := make([]types.WorkingFile, len(data.WorkingSession.WorkingFiles))
copy(updatedWorkingFiles, data.WorkingSession.WorkingFiles)

for i, wf := range updatedWorkingFiles {
for _, wfSubmit := range filesToSubmit {
if wf.Path == wfSubmit.Path {
state.workingSession.WorkingFiles[i] = wfSubmit
updatedWorkingFiles[i] = wfSubmit
}
}
}
data.WorkingSession.WorkingFiles = updatedWorkingFiles

// Clear input and set processing state
state.workingSession.InputText = ""
state.isProcessing = true
data.WorkingSession.InputText = ""
data.IsProcessing = true

// Add empty message for streaming response
state.workingSession.Messages = append(
state.workingSession.Messages,
data.WorkingSession.Messages = append(
data.WorkingSession.Messages,
types.Message{
AiServiceMessage: types.AiServiceMessage{Role: "assistant", Content: ""},
},
)
return true
})

// Get service messages for API call
serviceMessages := getServiceMessages(cirApp.state.workingSession.Messages)
serviceMessages := getServiceMessages(cirApp.appState.GetWorkingSession().Messages)

// Start streaming
resultChan, errChan := streamOpenAI(serviceMessages)
Expand All @@ -358,29 +340,30 @@ func (cirApp *CirApplication) handleStreamResponse(resultChan chan string, errCh
case chunk, ok := <-resultChan:
if !ok {
// Stream completed
cirApp.updateState(func(state *AppState) bool {
state.isProcessing = false
return true
cirApp.appState.Update(func(data *state.StateData) {
data.IsProcessing = false
})
return
}

accumulated += chunk

cirApp.updateState(func(state *AppState) bool {
lastIdx := len(state.workingSession.Messages) - 1
state.workingSession.Messages[lastIdx].AiServiceMessage.Content = accumulated
return true
cirApp.appState.Update(func(data *state.StateData) {
lastIdx := len(data.WorkingSession.Messages) - 1
if lastIdx >= 0 {
data.WorkingSession.Messages[lastIdx].AiServiceMessage.Content = accumulated
}
})

case err := <-errChan:
log.Printf("Error: %v", err)
if err != nil {
cirApp.updateState(func(state *AppState) bool {
lastIdx := len(state.workingSession.Messages) - 1
state.workingSession.Messages[lastIdx].Content = fmt.Sprintf("Error: %v", err)
state.isProcessing = false
return true
cirApp.appState.Update(func(data *state.StateData) {
lastIdx := len(data.WorkingSession.Messages) - 1
if lastIdx >= 0 {
data.WorkingSession.Messages[lastIdx].Content = fmt.Sprintf("Error: %v", err)
}
data.IsProcessing = false
})
return
}
Expand Down Expand Up @@ -465,8 +448,3 @@ print("Hello, World!")
}
return systemMessage
}

// GetKeyMappings returns the application's key mappings
// func (cirApp *CirApplication) GetKeyMappings() []KeyMapping {
// return cirApp.keyMappings
// }
Loading