chat-console 0.4.2__py3-none-any.whl → 0.4.6__py3-none-any.whl
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.
- app/__init__.py +1 -1
- app/api/ollama.py +1 -1
- app/console_chat.py +816 -0
- app/console_main.py +58 -0
- app/console_utils.py +195 -0
- app/main.py +267 -136
- app/ui/borders.py +154 -0
- app/ui/chat_interface.py +84 -78
- app/ui/model_selector.py +11 -3
- app/ui/styles.py +231 -137
- app/utils.py +22 -3
- {chat_console-0.4.2.dist-info → chat_console-0.4.6.dist-info}/METADATA +2 -2
- chat_console-0.4.6.dist-info/RECORD +28 -0
- {chat_console-0.4.2.dist-info → chat_console-0.4.6.dist-info}/WHEEL +1 -1
- chat_console-0.4.6.dist-info/entry_points.txt +5 -0
- chat_console-0.4.2.dist-info/RECORD +0 -24
- chat_console-0.4.2.dist-info/entry_points.txt +0 -3
- {chat_console-0.4.2.dist-info → chat_console-0.4.6.dist-info}/licenses/LICENSE +0 -0
- {chat_console-0.4.2.dist-info → chat_console-0.4.6.dist-info}/top_level.txt +0 -0
app/ui/borders.py
ADDED
@@ -0,0 +1,154 @@
|
|
1
|
+
"""
|
2
|
+
ASCII Border System for Clean, Functional Design
|
3
|
+
Following Dieter Rams' principle of "Less but better"
|
4
|
+
"""
|
5
|
+
|
6
|
+
# Clean ASCII border system with different weights for hierarchy
|
7
|
+
CLEAN_BORDERS = {
|
8
|
+
'light': {
|
9
|
+
'top_left': '┌', 'top_right': '┐', 'bottom_left': '└', 'bottom_right': '┘',
|
10
|
+
'horizontal': '─', 'vertical': '│', 'cross': '┼', 'tee_down': '┬',
|
11
|
+
'tee_up': '┴', 'tee_right': '├', 'tee_left': '┤'
|
12
|
+
},
|
13
|
+
'heavy': {
|
14
|
+
'top_left': '┏', 'top_right': '┓', 'bottom_left': '┗', 'bottom_right': '┛',
|
15
|
+
'horizontal': '━', 'vertical': '┃', 'cross': '╋', 'tee_down': '┳',
|
16
|
+
'tee_up': '┻', 'tee_right': '┣', 'tee_left': '┫'
|
17
|
+
},
|
18
|
+
'double': {
|
19
|
+
'top_left': '╔', 'top_right': '╗', 'bottom_left': '╚', 'bottom_right': '╝',
|
20
|
+
'horizontal': '═', 'vertical': '║', 'cross': '╬', 'tee_down': '╦',
|
21
|
+
'tee_up': '╩', 'tee_right': '╠', 'tee_left': '╣'
|
22
|
+
}
|
23
|
+
}
|
24
|
+
|
25
|
+
def create_border_line(width: int, border_type: str = 'light', position: str = 'top') -> str:
|
26
|
+
"""
|
27
|
+
Create a border line of specified width and type
|
28
|
+
|
29
|
+
Args:
|
30
|
+
width: Width of the border line
|
31
|
+
border_type: 'light', 'heavy', or 'double'
|
32
|
+
position: 'top', 'bottom', 'middle'
|
33
|
+
"""
|
34
|
+
borders = CLEAN_BORDERS[border_type]
|
35
|
+
|
36
|
+
if position == 'top':
|
37
|
+
return borders['top_left'] + borders['horizontal'] * (width - 2) + borders['top_right']
|
38
|
+
elif position == 'bottom':
|
39
|
+
return borders['bottom_left'] + borders['horizontal'] * (width - 2) + borders['bottom_right']
|
40
|
+
elif position == 'middle':
|
41
|
+
return borders['tee_right'] + borders['horizontal'] * (width - 2) + borders['tee_left']
|
42
|
+
else:
|
43
|
+
return borders['horizontal'] * width
|
44
|
+
|
45
|
+
def create_bordered_content(content: str, width: int, border_type: str = 'light', title: str = None) -> str:
|
46
|
+
"""
|
47
|
+
Create content with clean ASCII borders
|
48
|
+
|
49
|
+
Args:
|
50
|
+
content: Text content to border
|
51
|
+
width: Total width including borders
|
52
|
+
border_type: Border style
|
53
|
+
title: Optional title for the border
|
54
|
+
"""
|
55
|
+
borders = CLEAN_BORDERS[border_type]
|
56
|
+
inner_width = width - 2
|
57
|
+
|
58
|
+
lines = []
|
59
|
+
|
60
|
+
# Top border with optional title
|
61
|
+
if title:
|
62
|
+
title_line = f" {title} "
|
63
|
+
padding = (inner_width - len(title_line)) // 2
|
64
|
+
top_line = borders['top_left'] + borders['horizontal'] * padding + title_line
|
65
|
+
top_line += borders['horizontal'] * (inner_width - len(top_line) + 1) + borders['top_right']
|
66
|
+
lines.append(top_line)
|
67
|
+
else:
|
68
|
+
lines.append(create_border_line(width, border_type, 'top'))
|
69
|
+
|
70
|
+
# Content lines
|
71
|
+
content_lines = content.split('\n') if content else ['']
|
72
|
+
for line in content_lines:
|
73
|
+
# Truncate or pad line to fit
|
74
|
+
if len(line) > inner_width:
|
75
|
+
line = line[:inner_width-3] + '...'
|
76
|
+
else:
|
77
|
+
line = line.ljust(inner_width)
|
78
|
+
lines.append(borders['vertical'] + line + borders['vertical'])
|
79
|
+
|
80
|
+
# Bottom border
|
81
|
+
lines.append(create_border_line(width, border_type, 'bottom'))
|
82
|
+
|
83
|
+
return '\n'.join(lines)
|
84
|
+
|
85
|
+
def create_app_header(title: str, model: str, width: int) -> str:
|
86
|
+
"""
|
87
|
+
Create a clean application header following Rams design principles
|
88
|
+
"""
|
89
|
+
# Use light borders for the main frame
|
90
|
+
borders = CLEAN_BORDERS['light']
|
91
|
+
inner_width = width - 2
|
92
|
+
|
93
|
+
# Title and model info with intentional spacing
|
94
|
+
title_text = f" {title} "
|
95
|
+
model_text = f" Model: {model} "
|
96
|
+
|
97
|
+
# Calculate spacing
|
98
|
+
used_space = len(title_text) + len(model_text)
|
99
|
+
remaining = inner_width - used_space
|
100
|
+
spacing = '─' * max(0, remaining)
|
101
|
+
|
102
|
+
header_content = title_text + spacing + model_text
|
103
|
+
|
104
|
+
# Ensure it fits exactly
|
105
|
+
if len(header_content) > inner_width:
|
106
|
+
header_content = header_content[:inner_width]
|
107
|
+
elif len(header_content) < inner_width:
|
108
|
+
header_content = header_content.ljust(inner_width, '─')
|
109
|
+
|
110
|
+
return borders['top_left'] + header_content + borders['top_right']
|
111
|
+
|
112
|
+
def create_section_border(title: str, width: int, border_type: str = 'light') -> str:
|
113
|
+
"""
|
114
|
+
Create a section divider with title
|
115
|
+
"""
|
116
|
+
borders = CLEAN_BORDERS[border_type]
|
117
|
+
|
118
|
+
if title:
|
119
|
+
title_with_spaces = f" {title} "
|
120
|
+
remaining = width - len(title_with_spaces) - 2
|
121
|
+
left_dash = remaining // 2
|
122
|
+
right_dash = remaining - left_dash
|
123
|
+
|
124
|
+
return (borders['tee_right'] +
|
125
|
+
borders['horizontal'] * left_dash +
|
126
|
+
title_with_spaces +
|
127
|
+
borders['horizontal'] * right_dash +
|
128
|
+
borders['tee_left'])
|
129
|
+
else:
|
130
|
+
return borders['tee_right'] + borders['horizontal'] * (width - 2) + borders['tee_left']
|
131
|
+
|
132
|
+
# Minimal loading indicators following "less but better" principle
|
133
|
+
MINIMAL_LOADING_FRAMES = [
|
134
|
+
" ▪▫▫ ",
|
135
|
+
" ▫▪▫ ",
|
136
|
+
" ▫▫▪ ",
|
137
|
+
" ▫▪▫ ",
|
138
|
+
]
|
139
|
+
|
140
|
+
DOTS_LOADING = [
|
141
|
+
"●○○○",
|
142
|
+
"○●○○",
|
143
|
+
"○○●○",
|
144
|
+
"○○○●",
|
145
|
+
"○○●○",
|
146
|
+
"○●○○",
|
147
|
+
]
|
148
|
+
|
149
|
+
# Simple progress indicators
|
150
|
+
PROGRESS_BARS = {
|
151
|
+
'simple': ['▱', '▰'],
|
152
|
+
'blocks': ['░', '▓'],
|
153
|
+
'dots': ['○', '●']
|
154
|
+
}
|
app/ui/chat_interface.py
CHANGED
@@ -22,70 +22,72 @@ from ..config import CONFIG
|
|
22
22
|
logger = logging.getLogger(__name__)
|
23
23
|
|
24
24
|
class SendButton(Button):
|
25
|
-
"""
|
25
|
+
"""Minimal send button following Rams design principles"""
|
26
26
|
|
27
27
|
DEFAULT_CSS = """
|
28
|
-
/* Drastically simplified SendButton CSS */
|
29
28
|
SendButton {
|
30
|
-
|
31
|
-
|
32
|
-
|
29
|
+
background: transparent;
|
30
|
+
color: #E8E8E8;
|
31
|
+
border: solid #333333 1;
|
32
|
+
margin: 0 1;
|
33
|
+
padding: 1 2;
|
33
34
|
}
|
34
35
|
|
35
|
-
SendButton
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
36
|
+
SendButton:hover {
|
37
|
+
background: #1A1A1A;
|
38
|
+
border: solid #33FF33 1;
|
39
|
+
color: #E8E8E8;
|
40
|
+
}
|
41
|
+
|
42
|
+
SendButton:focus {
|
43
|
+
border: solid #33FF33 1;
|
44
|
+
outline: none;
|
40
45
|
}
|
41
46
|
"""
|
42
47
|
|
43
48
|
def __init__(self, name: Optional[str] = None):
|
44
49
|
super().__init__(
|
45
|
-
"
|
46
|
-
name=name
|
47
|
-
variant="success"
|
50
|
+
"→", # Simple arrow - functional and clear
|
51
|
+
name=name
|
48
52
|
)
|
49
53
|
|
50
|
-
def on_mount(self) -> None:
|
51
|
-
"""Handle mount event"""
|
52
|
-
self.styles.text_opacity = 100
|
53
|
-
self.styles.text_style = "bold"
|
54
|
-
|
55
54
|
class MessageDisplay(Static): # Inherit from Static instead of RichLog
|
56
55
|
"""Widget to display a single message using Static"""
|
57
56
|
|
58
57
|
DEFAULT_CSS = """
|
58
|
+
/* Rams-inspired message styling - "Less but better" */
|
59
59
|
MessageDisplay {
|
60
60
|
width: 100%;
|
61
|
-
height: auto;
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
transition: none; /* Fixed property name from 'transitions' to 'transition' */
|
61
|
+
height: auto;
|
62
|
+
margin: 2 0; /* Generous vertical spacing */
|
63
|
+
padding: 2 3; /* Intentional padding for readability */
|
64
|
+
text-wrap: wrap;
|
65
|
+
content-align: left top;
|
66
|
+
overflow-y: auto;
|
67
|
+
box-sizing: border-box;
|
68
|
+
background: transparent; /* Clean default */
|
69
|
+
border: none; /* Remove unnecessary borders */
|
71
70
|
}
|
72
71
|
|
73
72
|
MessageDisplay.user-message {
|
74
|
-
background:
|
75
|
-
border-
|
76
|
-
margin-
|
73
|
+
background: #1A1A1A; /* Subtle distinction */
|
74
|
+
border-left: solid #33FF33 2; /* Minimal accent line */
|
75
|
+
margin-left: 2; /* Slight indent */
|
76
|
+
margin-right: 8; /* Asymmetric layout for hierarchy */
|
77
77
|
}
|
78
78
|
|
79
79
|
MessageDisplay.assistant-message {
|
80
|
-
background:
|
81
|
-
border-left:
|
82
|
-
margin-
|
80
|
+
background: transparent; /* Clean background */
|
81
|
+
border-left: solid #666666 1; /* Subtle indicator */
|
82
|
+
margin-right: 2; /* Opposite indent */
|
83
|
+
margin-left: 8; /* Asymmetric layout */
|
83
84
|
}
|
84
85
|
|
85
86
|
MessageDisplay.system-message {
|
86
|
-
background:
|
87
|
-
border:
|
87
|
+
background: transparent;
|
88
|
+
border: solid #333333 1;
|
88
89
|
margin: 1 4;
|
90
|
+
color: #666666; /* Muted for less important messages */
|
89
91
|
}
|
90
92
|
"""
|
91
93
|
|
@@ -168,31 +170,31 @@ class MessageDisplay(Static): # Inherit from Static instead of RichLog
|
|
168
170
|
logger.error(f"Error refreshing app: {str(e)}")
|
169
171
|
|
170
172
|
def _format_content(self, content: str) -> str:
|
171
|
-
"""Format message content
|
173
|
+
"""Format message content following Rams principles - clean and functional"""
|
172
174
|
timestamp = datetime.now().strftime("%H:%M")
|
173
175
|
|
174
|
-
# Special handling for "Thinking..."
|
176
|
+
# Special handling for "Thinking..." - minimal styling
|
175
177
|
if content == "Thinking...":
|
176
|
-
|
177
|
-
return f"[dim]{timestamp}[/dim] [italic]{content}[/italic]"
|
178
|
+
return f" {timestamp} [dim]{content}[/dim]"
|
178
179
|
|
179
|
-
#
|
180
|
-
# Convert [text](url) to a safe format for Textual markup
|
180
|
+
# Clean up markdown-style links for better readability
|
181
181
|
content = re.sub(
|
182
182
|
r'\[([^\]]+)\]\(([^)]+)\)',
|
183
183
|
lambda m: f"{m.group(1)} ({m.group(2)})",
|
184
184
|
content
|
185
185
|
)
|
186
186
|
|
187
|
-
# Escape
|
187
|
+
# Escape markup characters but keep content clean
|
188
188
|
content = content.replace("[", "\\[").replace("]", "\\]")
|
189
|
-
# But keep our timestamp markup
|
190
|
-
timestamp_markup = f"[dim]{timestamp}[/dim]"
|
191
189
|
|
192
|
-
#
|
193
|
-
|
190
|
+
# Rams principle: "As little design as possible"
|
191
|
+
# Simple timestamp with generous spacing for readability
|
192
|
+
return f" {timestamp} {content}"
|
194
193
|
|
195
|
-
|
194
|
+
def _get_clean_border(self, width: int = 60) -> str:
|
195
|
+
"""Create a clean ASCII border following the design spec"""
|
196
|
+
from .borders import create_border_line
|
197
|
+
return create_border_line(width, 'light', 'top')
|
196
198
|
|
197
199
|
class InputWithFocus(Input):
|
198
200
|
"""Enhanced Input that better handles focus and maintains cursor position"""
|
@@ -218,66 +220,74 @@ class ChatInterface(Container):
|
|
218
220
|
"""Main chat interface container"""
|
219
221
|
|
220
222
|
DEFAULT_CSS = """
|
223
|
+
/* Clean chat interface following Rams principles */
|
221
224
|
ChatInterface {
|
222
225
|
width: 100%;
|
223
226
|
height: 100%;
|
224
|
-
background:
|
227
|
+
background: #0C0C0C;
|
225
228
|
}
|
226
229
|
|
227
230
|
#messages-container {
|
228
231
|
width: 100%;
|
229
232
|
height: 1fr;
|
230
|
-
min-height:
|
231
|
-
border-bottom: solid
|
233
|
+
min-height: 15; /* More breathing room */
|
234
|
+
border-bottom: solid #333333 1;
|
232
235
|
overflow: auto;
|
233
|
-
padding:
|
234
|
-
content-align: left top;
|
236
|
+
padding: 2; /* Generous padding */
|
237
|
+
content-align: left top;
|
235
238
|
box-sizing: border-box;
|
236
|
-
|
239
|
+
background: #0C0C0C;
|
237
240
|
}
|
238
241
|
|
239
242
|
#input-area {
|
240
243
|
width: 100%;
|
241
244
|
height: auto;
|
242
|
-
min-height:
|
243
|
-
max-height:
|
244
|
-
padding:
|
245
|
+
min-height: 5; /* Comfortable minimum */
|
246
|
+
max-height: 12;
|
247
|
+
padding: 2; /* Consistent padding */
|
248
|
+
background: #0C0C0C;
|
249
|
+
border-top: solid #333333 1;
|
245
250
|
}
|
246
251
|
|
247
252
|
#message-input {
|
248
253
|
width: 1fr;
|
249
|
-
min-height:
|
254
|
+
min-height: 3; /* Comfortable input height */
|
250
255
|
height: auto;
|
251
256
|
margin-right: 1;
|
252
|
-
border: solid
|
257
|
+
border: solid #333333 1;
|
258
|
+
background: #0C0C0C;
|
259
|
+
color: #E8E8E8;
|
260
|
+
padding: 1;
|
253
261
|
}
|
254
262
|
|
255
263
|
#message-input:focus {
|
256
|
-
border: solid
|
264
|
+
border: solid #33FF33 1;
|
265
|
+
outline: none;
|
257
266
|
}
|
258
267
|
|
259
268
|
#version-label {
|
260
269
|
width: 100%;
|
261
|
-
height:
|
262
|
-
background:
|
263
|
-
color:
|
270
|
+
height: auto;
|
271
|
+
background: #0C0C0C;
|
272
|
+
color: #666666; /* Muted version info */
|
264
273
|
text-align: right;
|
265
|
-
padding:
|
266
|
-
|
274
|
+
padding: 1;
|
275
|
+
border-bottom: solid #333333 1;
|
267
276
|
}
|
268
277
|
|
269
278
|
#loading-indicator {
|
270
279
|
width: 100%;
|
271
|
-
height:
|
272
|
-
background:
|
273
|
-
color:
|
280
|
+
height: 2;
|
281
|
+
background: #0C0C0C;
|
282
|
+
color: #666666; /* Subtle loading indicator */
|
274
283
|
display: none;
|
275
|
-
padding: 0
|
284
|
+
padding: 0 2;
|
285
|
+
text-align: center;
|
286
|
+
border-bottom: solid #333333 1;
|
276
287
|
}
|
277
288
|
|
278
289
|
#loading-indicator.model-loading {
|
279
|
-
|
280
|
-
color: $text;
|
290
|
+
color: #33FF33; /* Accent for model loading */
|
281
291
|
}
|
282
292
|
"""
|
283
293
|
|
@@ -404,21 +414,17 @@ class ChatInterface(Container):
|
|
404
414
|
input_widget.focus()
|
405
415
|
|
406
416
|
def start_loading(self, model_loading: bool = False) -> None:
|
407
|
-
"""Show loading indicator
|
408
|
-
|
409
|
-
Args:
|
410
|
-
model_loading: If True, indicates Ollama is loading a model
|
411
|
-
"""
|
417
|
+
"""Show minimal loading indicator following Rams principles"""
|
412
418
|
self.is_loading = True
|
413
419
|
loading = self.query_one("#loading-indicator")
|
414
420
|
loading_text = self.query_one("#loading-text")
|
415
421
|
|
416
422
|
if model_loading:
|
417
423
|
loading.add_class("model-loading")
|
418
|
-
loading_text.update("
|
424
|
+
loading_text.update("● Preparing model")
|
419
425
|
else:
|
420
426
|
loading.remove_class("model-loading")
|
421
|
-
loading_text.update("
|
427
|
+
loading_text.update("● Generating")
|
422
428
|
|
423
429
|
loading.display = True
|
424
430
|
|
app/ui/model_selector.py
CHANGED
@@ -17,18 +17,20 @@ logger = logging.getLogger(__name__)
|
|
17
17
|
class ModelSelector(Container):
|
18
18
|
"""Widget for selecting the AI model to use"""
|
19
19
|
|
20
|
+
# Rams-inspired selector styling
|
20
21
|
DEFAULT_CSS = """
|
21
22
|
ModelSelector {
|
22
23
|
width: 100%;
|
23
24
|
height: auto;
|
24
|
-
padding:
|
25
|
-
background:
|
25
|
+
padding: 1;
|
26
|
+
background: #0C0C0C;
|
27
|
+
border: solid #333333 1;
|
26
28
|
}
|
27
29
|
|
28
30
|
#selector-container {
|
29
31
|
width: 100%;
|
30
32
|
layout: horizontal;
|
31
|
-
height:
|
33
|
+
height: auto;
|
32
34
|
padding: 0;
|
33
35
|
}
|
34
36
|
|
@@ -36,11 +38,17 @@ class ModelSelector(Container):
|
|
36
38
|
width: 30%;
|
37
39
|
height: 3;
|
38
40
|
margin-right: 1;
|
41
|
+
background: #0C0C0C;
|
42
|
+
color: #E8E8E8;
|
43
|
+
border: solid #333333 1;
|
39
44
|
}
|
40
45
|
|
41
46
|
#model-select, #custom-model-input {
|
42
47
|
width: 1fr;
|
43
48
|
height: 3;
|
49
|
+
background: #0C0C0C;
|
50
|
+
color: #E8E8E8;
|
51
|
+
border: solid #333333 1;
|
44
52
|
}
|
45
53
|
|
46
54
|
#custom-model-input {
|